JavaIO 实战 —— dex 文件加密

上一篇文章介绍了 IO 的基础知识,本节我们来举一个 IO 的应用实例——Dex 文件加密。当然,通常情况下我们是使用 Gradle 进行 Dex 加密的,这里使用 IO 权当是练手了。

1、理论知识

1.1 了解反编译

解压 apk 文件可以得到如下内容:

  • META_INF 文件夹:签名信息。
  • res 文件夹:资源。
  • AndroidManifest.xml:清单文件。
  • classes.dex:类似于 jar 包,反编译主要就是通过该文件找到源代码。如果没有做过 App 加固或至少做个 progurad 的话,相当于你的 App 毫无秘密可言了。但是 proguard 仅仅是做了成员名称的替换,但是代码逻辑还是能看清的,因此它的意义并不大。
  • resources.arsc:资源映射表。

通过 jd-gui 工具可以查看反编译的 class 文件,过程是先解压 apk 文件,然后使用 dex2jar 将 class.dex 编成 jar 包,最后使用 jd-gui 查看 class 文件。

1.2 加固方案

常用的加固方案有以下三种:

  1. 反模拟器:模拟器运行 apk,可以用模拟器监控到 apk 的各种行为(所有人都可以自己使用 Google 提供的代码编译一个模拟器,从而自己添加监控 app 行为的代码,如打印网络请求信息等,运行 app 时就能监控到该 app 的重要信息了),所以在实际的加固 apk 运行时,一旦发现模拟器在运行该 apk,就停止核心代码的运行。
  2. 代码虚拟化:代码虚拟化在桌面平台应用保护中已经是非常的常见了,主要的思路是自建一个虚拟执行引擎,然后把原生的可执行代码转换成自定义的指令进行虚拟执行(把原本的应用 -> 虚拟机变成应用 -> 虚拟执行引擎 -> 虚拟机,增加了编码方案,应用运行在虚拟引擎上,而不是原本的系统的虚拟机上)。
  3. 加密:样本的部分可执行代码是以压缩或者加密的形式存在的。比如,被保护过的代码被切割成多个小段,前面的一段代码先把后面的代码片段在内存中解密,然后再去执行解密之后的代码,如此一块块的迭代执行。

对于加密的方案,dex 文件要先被 ClassLoader 加载进内存后,它包含的类才能运行。每个 dex 文件中的代码可以分为核心代码与非核心代码,只对核心代码进行加固,当非核心代码调用到核心代码时,对核心代码进行解密后运行。

我们把一个 dex 文件分为壳 dex 和源 dex,源 dex 被加密,壳 dex 不加密而且要负责解密源 dex。当它们需要运行时,先被加载进内存,然后用壳 dex 对源 dex 进行解密后才可执行源 dex 的代码。示意图如下:

除了框架图中画出的内容之外,还有几个问题需要先弄清楚:

1. dex 文件可以随便拼凑吗?
2. 壳 dex 怎么来的?
3. 如何签名?
4. 如何运行新 apk(如何脱壳)?

我们先来了解下 dex 文件。

1.3 dex 文件

加固的目的是保护 dex,直接而言就是对 dex 文件进行操作,操作前必须知道 dex 文件是什么、什么是源 dex、什么是壳 dex。以下是 dex 文件的结构图:

类的定义区包含变量和方法的定义信息,但是不包含方法内具体的执行代码。因此在反编译工具中能看到方法是如何定义声明的,但是看不到方法体。

1.4 Apk 打包流程

在这里插入图片描述

详细过程可以参考APK打包流程apk打包流程

2、代码实现

由于加固并不属于 app 本身的功能,所以可以在业务模块外单独建一个 Android Library 按照如下流程进行操作:APK -> unzip -> 过滤文件 -> 找出所有 dex 文件 -> 加密得到源 dex -> 获取壳 dex -> 打包壳&源 dex 后签名。

2.1 解压 apk 并加密 dex

先将需要加固的 apk 解压到指定的临时目录,然后把 dex 文件读取成字节的形式,对这些字节进行加密,最后写到临时目录中同名的 dex 文件中,示例代码:

        /*
          第一步:解压原始apk,找到dex文件并加密
         */
        AES.init(AES.DEFAULT_PWD);
        File apkFile = new File("source/apk/app-debug.apk");
        File newApkFile = new File(apkFile.getParent() + File.separator + "temp");
        if (!newApkFile.exists()) {
            newApkFile.mkdirs();
        }
        // 拿到主 dex 加密后的文件
        File mainDexFile = AES.encryptAPKFile(apkFile, newApkFile);
        // 把加密过的 dex 文件重命名一下,在名字和.之间加一个_
        if (newApkFile.isDirectory()) {
            File[] files = newApkFile.listFiles(new FilenameFilter() {
                @Override
                public boolean accept(File dir, String name) {
                    return name.endsWith(".dex");
                }
            });

            if (files != null) {
                for (File file : files) {
                    String name = file.getName();
                    System.out.println("name:" + name);
                    int cursor = name.indexOf(".dex");
                    // 生成的文件名字要加上前面的路径,否则就会在项目根目录下生成重命名后的文件了
                    String newName = file.getParent() + File.separator + name.substring(0, cursor) + "_" + ".dex";
                    file.renameTo(new File(newName));
                }
            }
        }

AES 类专门做加密解密工作,上边用到了 AES 的初始化和加密方法:

    public static final String DEFAULT_PWD = "abcdefghijklmnop";
    private static final String algorithmStr = "AES/ECB/PKCS5Padding";

    private static Cipher encryptCipher;
    private static Cipher decryptCipher;
    
    /**
     * 生成一个实现指定转换的 Cipher 对象。
     */
    public static void init(String password) {
        try {
            encryptCipher = Cipher.getInstance(algorithmStr);
            decryptCipher = Cipher.getInstance(algorithmStr);
            byte[] passwordBytes = password.getBytes();
            SecretKeySpec keySpec = new SecretKeySpec(passwordBytes, "AES");
            encryptCipher.init(Cipher.ENCRYPT_MODE, keySpec);
            decryptCipher.init(Cipher.DECRYPT_MODE, keySpec);
        } catch (NoSuchAlgorithmException | InvalidKeyException | NoSuchPaddingException e) {
            e.printStackTrace();
        }
    }
    
    /**
     * 把 srcAPKFile 表示的 apk 文件先解压到 dstApkFile 路径中,然后将 dstApkFile 目录下的
     * dex 文件以字节的形式加密并写回
     *
     * @param srcAPKFile 源文件
     * @param dstApkFile 目标文件
     * @return 加密后的主dex文件
     */
    public static File encryptAPKFile(File srcAPKFile, File dstApkFile) throws Exception {
        if (srcAPKFile == null) {
            throw new NullPointerException("srcAPKFile is null!");
        }

        Zip.unzip(srcAPKFile, dstApkFile);

        // 获取所有 dex 文件(有分包情况,因此有不止一个 dex 文件)
        File[] dexFiles = dstApkFile.listFiles(new FilenameFilter() {
            @Override
            public boolean accept(File dir, String name) {
                return name.endsWith(".dex");
            }
        });

        File mainDexFile = null;

        if (dexFiles != null) {
            for (File dexFile : dexFiles) {
                // 把文件读取成字节数组
                byte[] buffer = Utils.getBytes(dexFile);
                // 对字节数组进行加密
                byte[] encryptBytes = AES.encrypt(buffer);

                if (dexFile.getName().endsWith("classes.dex")) {
                    mainDexFile = dexFile;
                }

                // 把加密后的字节数据写回到 dex 文件中
                FileOutputStream fos = new FileOutputStream(dexFile);
                fos.write(encryptBytes);
                fos.close();
            }
        }

        return mainDexFile;
    }

加密方法中是先将 apk 解压到临时目录了,然后用 FilenameFilter 过滤出 dex 文件,遍历这些 dex 文件时,把它们读取成字节形式并对字节进行加密:

Utils.java:

    public static byte[] getBytes(File file) throws IOException {
        RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r");
        // 一次性把文件中的内容读取到 buffer 中,这个过程是在 Windows 系统下运行的,
        // 而不是在 JVM 中,因此不用担心占用内存过大。
        byte[] buffer = new byte[(int) randomAccessFile.length()];
        randomAccessFile.readFully(buffer);
        randomAccessFile.close();
        return buffer;
    }
    
AES.java:
   private static byte[] encrypt(byte[] bytes) {
        try {
            return encryptCipher.doFinal(bytes);
        } catch (BadPaddingException | IllegalBlockSizeException e) {
            e.printStackTrace();
        }
        return null;
    }

到这里加密工作结束。

2.2 获取壳 dex

壳 dex 的作用:

  1. 对已经加密的 dex 文件进行解密。
  2. 正常加载解密后的 dex 文件。

因此壳 dex 是不需要被加密的。对于本例而言,我们采用的方式是在一个 Android Library 模块中使用 Application 去解密,而 app 模块直接声明使用 Android Library 的 Application(在真正的商业项目开发中,由于 app 模块中一定是有一个自己的 Application 的,如果我们这个 Android Library 模块也要通过 Application 去解密和加载源 dex,那么只能是用插件化方式,让模块中的 Application 去 hook app 中的 Application 使得两个 Application 都得以执行)。

比如说 Android Library 模块 encryptdex 使用的 Application 的名称是 ShellApplication,那么 app 模块就要在 build.gradle 和 AndroidManifest.xml 中做如下配置:

dependencies {
    implementation project(':encryptdex')
}

    <application
        android:name="com.frank.encryptdex.ShellApplication"/>

这样当 app 模块运行 Application 时,才会运行 ShellApplication,进而完成脱壳和加载 dex 的操作。

Android Library 模块编译出来的产物是 aar 文件。它的结构与 apk 文件相比,没有签名文件,并且没有 class.dex 文件,而是 class.jar 文件。制作壳 dex 文件,就是将这个 aar 文件解压,得到 class.jar 文件再转变成壳 dex。具体操作:

        /*
        第二步:处理 aar 文件,获得壳 dex 文件
         */
        File aarFile = new File("source/aar/encryptdex-debug.aar");
        File aarDexFile = Dx.jar2Dex(aarFile);

        // 把 source/aar/temp/classes.dex 复制到 source/apk/temp 目录下
        File tempMainDex = new File(newApkFile.getPath() + File.separator + "classes.dex");
        if (!tempMainDex.exists()) {
            tempMainDex.createNewFile();
        }

        FileOutputStream fos = new FileOutputStream(tempMainDex);
        byte[] buffer = Utils.getBytes(aarDexFile);
        fos.write(buffer);
        fos.close();

Dx.jar2Dex() 执行了将 aar 文件解压并将其内部的 classes.jar 转换成 dex 文件的操作:

    /**
     * aar 文件放在 source/aar/encryptdex-debug.aar 路径下,将它解压到 source/aar/temp 目录下。
     *
     * @param aarFile
     */
    public static File jar2Dex(File aarFile) {
        // 构造 source/aar/temp 目录
        File fakeDex = new File(aarFile.getParent() + File.separator + "temp");
        Zip.unzip(aarFile, fakeDex);

        // 找到 classes.jar 文件
        File[] files = fakeDex.listFiles(new FilenameFilter() {
            @Override
            public boolean accept(File dir, String name) {
                return name.endsWith("classes.jar");
            }
        });

        if (files == null || files.length <= 0) {
            throw new RuntimeException("the aar is invalidate");
        }

        // 使用 android tools 里面的 dx.bat 命令将 classes.jar 转换成 dex 文件
        File jarFile = files[0];
        File dexFile = new File(jarFile.getParent(), "classes.dex");
        dxCommand(jarFile, dexFile);
        return dexFile;
    }
    
    /**
     * 在 Windows 下使用 dx.bat 命令将 jar 文件转换成 dex 文件
     *
     * @param jarFile
     * @param dexFile
     */
    private static void dxCommand(File jarFile, File dexFile) {
        Runtime runtime = Runtime.getRuntime();
        Process process = null;
        try {
            process = runtime.exec("cmd.exe /C dx --dex --output=" +
                    dexFile.getAbsolutePath() + " " + jarFile.getAbsolutePath());
            process.waitFor();

            // 没有正常执行完的情况,log 输出信息并抛出异常
            if (process.exitValue() != 0) {
                InputStream is = process.getErrorStream();
                ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
                byte[] buffer = new byte[2048];
                int length;
                while ((length = is.read(buffer)) != -1) {
                    outputStream.write(buffer, 0, length);
                }
                System.out.println(new String(outputStream.toByteArray(), "GBK"));
                throw new RuntimeException("dx run failed!");
            }
        } catch (IOException | InterruptedException e) {
            e.printStackTrace();
        } finally {
            if (process != null) {
                process.destroy();
            }
        }
    }

dxCommand() 其实就是执行了 dx 命令将 jar 文件转换成 dex 文件。到这就得到了这个壳 dex classes.dex 文件。

2.3 打包签名

接下来就要把壳 dex 移动到第1步得到的存放已经加密过的 dex 文件所在的临时目录中,然后将该目录下所有的文件打包成 apk 文件再签名。步骤如下:

        /*
        第三步:打包签名
         */
        File unsignedApk = new File("source/result/apk-unsigned.apk");
        unsignedApk.getParentFile().mkdirs();

        // 把 source/apk/temp 目录下的文件全部压缩到 unsignedApk 文件中
        Zip.zip(newApkFile, unsignedApk);
        // 不用插件就不能自动使用原apk的签名...
        File signedApk = new File("source/result/apk-signed.apk");
        Signature.signature(unsignedApk, signedApk);

重点是 Signature 执行签名的过程:

public class Signature {

    public static void signature(File unsignedApk, File signedApk) throws InterruptedException, IOException {
        // 执行的命令是键值对形式
        String cmd[] = {"cmd.exe", "/C ", "jarsigner",
                "-sigalg", "MD5withRSA",
                "-digestalg", "SHA1",
                "-keystore", "C:\\Users\\69129\\demo.keystore",
                "-storepass", "123456",
                "-keypass", "123456",
                "-signedjar", signedApk.getAbsolutePath(),
                unsignedApk.getAbsolutePath(),
                "mykey"};

        Process process = Runtime.getRuntime().exec(cmd);
        System.out.println("start sign");

        // 对执行结果信息进行输出
        int waitResult = process.waitFor();
        System.out.println("waitResult: " + waitResult);

        System.out.println("process.exitValue() " + process.exitValue());
        if (process.exitValue() != 0) {
            InputStream inputStream = process.getErrorStream();
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            byte[] buffer = new byte[2048];
            int length;
            while ((length = inputStream.read(buffer)) != -1) {
                bos.write(buffer, 0, length);
            }
            System.out.println(new String(bos.toByteArray(), "GBK"));
        }

        System.out.println("finish signed");
        process.destroy();
    }
}

与 dx 类似其实也是在 cmd 中执行了一条指令完成的打包。使用 jarsigner 指令打包前,需要先新建一个 keystore:

D:\>keytool -genkey -alias demo.keystore -keyalg RSA -validity 40000 -keystore demo.keystore

/*说明:-genkey 产生密钥
       -alias demo.keystore 别名 demo.keystore
       -keyalg RSA 使用RSA算法对签名加密
       -validity 40000 有效期限4000天
       -keystore demo.keystore */

生成了这个 demo.keystore 之后要记好 keystore 的密码,然后才使用 jarsigner 对 apk 进行签名:

D:\>jarsigner -verbose -keystore demo.keystore -signedjar demo_signed.apk demo.apk demo.keystore

/*说明:-verbose 输出签名的详细信息
       -keystore  demo.keystore 密钥库位置
       -signedjar demor_signed.apk demo.apk demo.keystore 正式签名,三个参数中依次为签名后产生的文件demo_signed,要签名的文件demo.apk和密钥库demo.keystore.*/

如果忘记了 keystore 文件是如何配置的,也可以通过命令查看 keystore 的信息:

keytool -v -list -keystore demo.keystore

2.4 脱壳

加密、签名过后得到的 apk 文件可以安装了,但是不能运行,因为还没解密。

运行时才脱壳,安装时没有执行代码,所以解密不是在安装阶段,而是在运行阶段,并且是在运行阶段的越早越好,因为不解密,被加固的代码就无法执行。所以解密代码要写在 Application 的 attachBaseContent() 中(attachBaseContent() 比 onCreate() 早):

    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);

        AES.init(getPassword());
        File apkFile = new File(getApplicationInfo().sourceDir);
        //data/data/包名/files/fake_apk/
        File unZipFile = getDir("fake_apk", MODE_PRIVATE);
        File app = new File(unZipFile, "app");
        if (!app.exists()) {
            Zip.unZip(apkFile, app);
            File[] files = app.listFiles();
            for (File file : files) {
                String name = file.getName();
                if (name.equals("classes.dex")) {
                    // 本例中 classes.dex 是壳 dex,已经被加载了,这里不用再处理。
                } else if (name.endsWith(".dex")) {
                    try {
                        byte[] bytes = getBytes(file);
                        FileOutputStream fos = new FileOutputStream(file);
                        byte[] decrypt = AES.decrypt(bytes);
                        fos.write(decrypt);
                        fos.close();
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }
        }
        List list = new ArrayList<>();
        Log.d("FAKE", Arrays.toString(app.listFiles()));
        for (File file : app.listFiles()) {
            if (file.getName().endsWith(".dex")) {
                list.add(file);
            }
        }

        Log.d("FAKE", list.toString());
        try {
            V19.install(getClassLoader(), list, unZipFile);
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        }
    }

还是先将 apk 文件解压,解压出来的 dex 文件中,classes.dex 是壳 dex 已经被系统加载了,需要重新用 ClassLoader 加载的是剩下的加密的 dex。对这些 dex 先解密,然后通过反射让 ClassLoader 重新加载这些 dex 文件进内存。这个过程就跟热修复的最终过程是一样的了,先贴出一段代码,在 API < 27 是可行的:

    private static final class V19 {
        private V19() {
        }

        private static void install(ClassLoader loader, List<File> additionalClassPathEntries,
                                    File optimizedDirectory) throws IllegalArgumentException,
                IllegalAccessException, NoSuchFieldException, InvocationTargetException,
                NoSuchMethodException {

            Field pathListField = findField(loader, "pathList");
            Object dexPathList = pathListField.get(loader);
            ArrayList suppressedExceptions = new ArrayList();
            Log.d(TAG, "Build.VERSION.SDK_INT " + Build.VERSION.SDK_INT);
            if (Build.VERSION.SDK_INT >= 23) {
                expandFieldArray(dexPathList, "dexElements", makePathElements(dexPathList, new
                                ArrayList(additionalClassPathEntries), optimizedDirectory,
                        suppressedExceptions));
            } else {
                expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList, new
                                ArrayList(additionalClassPathEntries), optimizedDirectory,
                        suppressedExceptions));
            }

            if (suppressedExceptions.size() > 0) {
                Iterator suppressedExceptionsField = suppressedExceptions.iterator();

                while (suppressedExceptionsField.hasNext()) {
                    IOException dexElementsSuppressedExceptions = (IOException)
                            suppressedExceptionsField.next();
                    Log.w("MultiDex", "Exception in makeDexElement",
                            dexElementsSuppressedExceptions);
                }

                Field suppressedExceptionsField1 = findField(loader,
                        "dexElementsSuppressedExceptions");
                IOException[] dexElementsSuppressedExceptions1 = (IOException[]) ((IOException[])
                        suppressedExceptionsField1.get(loader));
                if (dexElementsSuppressedExceptions1 == null) {
                    dexElementsSuppressedExceptions1 = (IOException[]) suppressedExceptions
                            .toArray(new IOException[suppressedExceptions.size()]);
                } else {
                    IOException[] combined = new IOException[suppressedExceptions.size() +
                            dexElementsSuppressedExceptions1.length];
                    suppressedExceptions.toArray(combined);
                    System.arraycopy(dexElementsSuppressedExceptions1, 0, combined,
                            suppressedExceptions.size(), dexElementsSuppressedExceptions1.length);
                    dexElementsSuppressedExceptions1 = combined;
                }

                suppressedExceptionsField1.set(loader, dexElementsSuppressedExceptions1);
            }

        }

        private static Object[] makeDexElements(Object dexPathList,
                                                ArrayList<File> files, File
                                                        optimizedDirectory,
                                                ArrayList<IOException> suppressedExceptions) throws
                IllegalAccessException, InvocationTargetException, NoSuchMethodException {

            Method makeDexElements = findMethod(dexPathList, "makeDexElements", new
                    Class[]{ArrayList.class, File.class, ArrayList.class});
            return ((Object[]) makeDexElements.invoke(dexPathList, new Object[]{files,
                    optimizedDirectory, suppressedExceptions}));
        }
    }

    /**
     * A wrapper around
     * {@code private static final dalvik.system.DexPathList#makePathElements}.
     */
    private static Object[] makePathElements(
            Object dexPathList, ArrayList<File> files, File optimizedDirectory,
            ArrayList<IOException> suppressedExceptions)
            throws IllegalAccessException, InvocationTargetException, NoSuchMethodException {

        Method makePathElements;
        try {
            makePathElements = findMethod(dexPathList, "makePathElements", List.class, File.class,
                    List.class);
        } catch (NoSuchMethodException e) {
            Log.e(TAG, "NoSuchMethodException: makePathElements(List,File,List) failure");
            try {
                makePathElements = findMethod(dexPathList, "makePathElements", ArrayList.class, File.class, ArrayList.class);
            } catch (NoSuchMethodException e1) {
                Log.e(TAG, "NoSuchMethodException: makeDexElements(ArrayList,File,ArrayList) failure");
                try {
                    Log.e(TAG, "NoSuchMethodException: try use v19 instead");
                    return V19.makeDexElements(dexPathList, files, optimizedDirectory, suppressedExceptions);
                } catch (NoSuchMethodException e2) {
                    Log.e(TAG, "NoSuchMethodException: makeDexElements(List,File,List) failure");
                    throw e2;
                }
            }
        }
        return (Object[]) makePathElements.invoke(dexPathList, files, optimizedDirectory, suppressedExceptions);
    }

代码没整理,写的混乱,因为后续还要重新再写。原因是这些代码在 API >= 27 就不可用了,因为 makePathElements() 的返回值那个语句使用的反射会抛出异常:

Opening an oat file without a class loader.Are you using the deprecated DexFile APIs?

抛出异常的原因是,Google 在我们反射的源码 DexFile 类加上了 @Deprecated,并且在底层的 oat_file_manager.cc 文件中加入了判断:

      if (class_loader == nullptr) {
        LOG(WARNING) << "Opening an oat file without a class loader. "
                     << "Are you using the deprecated DexFile APIs?";
        context = nullptr;
      } else {
        context = ClassLoaderContext::CreateContextForClassLoader(class_loader, dex_elements);
      }

它输出的 log 内容正是我们看到的 log 中被抛出的异常内容。

所以在 API >= 27 时不能再使用上面的方法了,可以参考腾讯 Tinker 热修复的做法:
https://github.com/Tencent/tinker/blob/master/tinker-android%2Ftinker-android-loader%2Fsrc%2Fmain%2Fjava%2Fcom%2Ftencent%2Ftinker%2Floader%2FNewClassLoaderInjector.java

https://github.com/Tencent/tinker/blob/master/tinker-android/tinker-android-loader/src/main/java/com/tencent/tinker/loader/SystemClassLoaderAdder.java

参考资料

加固的参考资料:
10-实战开发APK安全加固
Android apk加固实现原理
Android apk加固实现原理(二)
apk加固
Android APK加固完善篇
[Android 分享] 安卓apk简单加固防修改

AES 加密算法相关的:
AES加密算法的详细介绍与实现
使用java实现AES加密
Java实现AES加密

压缩相关:
Java操作zip-压缩和解压文件
java实现对rar文件和zip文件的解压缩
利用java zip进行对文件的压缩和解压

签名相关:
Android学习系列(1)–为App签名(为apk签名)
给Android的APK程序签名和重新签名的方法
Android apk签名的两种方法
cmd里查看jks签名信息,更改别名,删除别名,查看apk签名信息,keytool高级用法

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值