app加固原理(一)

前言

apk正常打包后可以通过 反编译工具使用 得到源码,那这么长时间的辛苦不就白费了吗,这就引出一个问题了:怎么保证不让别人不容易拿到源码呢?

当然得是通过加固啦,使用第三方的加固工具 (结尾给大家),但是作为一名热爱学习的程序员,当然得明白其中的原理才好。

app加固原理

加固原理.png
  1. 制作一个壳程序 (功能:解密和加载dex文件)
  2. 使用加密工具对原apk的dex文件进行加密
  3. 最后重新打包、对齐、签名

实现

1. 制作壳程序

  • 制作壳程序,壳程序包含两功能解密dex文件和加载dex文件,先说加载dex,

  • 解密dex文件:解压apk包得到dex文件,然后把加密过的dex文件进行解密

  • 那系统又是怎么加载dex文件呢?

Android源码目录\libcore\dalvik\src\main\java\dalvik\system\DexClassLoader.java
public class DexClassLoader extends BaseDexClassLoader {
    public DexClassLoader(String dexPath, String optimizedDirectory,
            String librarySearchPath, ClassLoader parent) {
        super(dexPath, new File(optimizedDirectory), librarySearchPath, parent);
    }
}

这里调用父类的构造方法

Android源码目录\libcore\dalvik\src\main\java\dalvik\system\BaseDexClassLoader.java

看下面这个方法

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
        //这里传个名字 和 集合, 就是说把某个类进行加载 
        Class c = pathList.findClass(name, suppressedExceptions);
        if (c == null) {
            ClassNotFoundException cnfe = new ClassNotFoundException(
                    "Didn't find class \"" + name + "\" on path: " + pathList);
            for (Throwable t : suppressedExceptions) {
                cnfe.addSuppressed(t);
            }
            throw cnfe;
        }
        return c;
}
Android源码目录\libcore\dalvik\src\main\java\dalvik\system\DexPathList.java
public Class findClass(String name, List<Throwable> suppressed) {
       //通过遍历dexElements去加载
       for (Element element : dexElements) {
            DexFile dex = element.dexFile;

            if (dex != null) {
                Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
                if (clazz != null) {
                    return clazz;
                }
            }
        }
        if (dexElementsSuppressedExceptions != null) {
            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
      }
        return null;
}

从这个方法中看到,dex是通过遍历dexElements去加载的,可以通过反射dexElements拿到已经加载的dex文件,那我们看dexElements的初始化

  //dexElements 初始化
this.dexElements = makePathElements(splitDexPath(dexPath), optimizedDirectory,
                                            suppressedExceptions);


 private static Element[] makePathElements(List<File> files, File optimizedDirectory,
                                              List<IOException> suppressedExceptions) {
............................                                              
}

那我们通过反射调用这个方法把解密后的dex文件通过makePathElements方法反射 加载进来,再和原来的dex合并,那这个app就能运行了。

3. 重新打包、对齐、签名

  1. 重新打包
    把壳程序的dex文件和加密后的文件进行打包
  2. 对齐

zipalign -v -p 4 input_unsigned.apk output_unsigned.apk

  1. 签名

apksigner sign --ks jks文件地址 --ks-key-alias 别名 --ks-pass pass:jsk密码 --key-pass pass:别名密码 --out out.apk in.apk

代码实现

壳程序

  • 加密算法
    public class AES {
    
      //16字节
      public static final String DEFAULT_PWD = "abcdefghijklmnop";
      //填充方式
      private static final String algorithmStr = "AES/ECB/PKCS5Padding";
      private static Cipher encryptCipher;
      private static Cipher decryptCipher;
    
      public static void init(String password) {
          try {
              // 生成一个实现指定转换的 Cipher 对象。
              encryptCipher = Cipher.getInstance(algorithmStr);
              decryptCipher = Cipher.getInstance(algorithmStr);// algorithmStr
              byte[] keyStr = password.getBytes();
              SecretKeySpec key = new SecretKeySpec(keyStr, "AES");
              encryptCipher.init(Cipher.ENCRYPT_MODE, key);
              decryptCipher.init(Cipher.DECRYPT_MODE, key);
          } catch (NoSuchAlgorithmException e) {
              e.printStackTrace();
          } catch (NoSuchPaddingException e) {
              e.printStackTrace();
          } catch (InvalidKeyException e) {
              e.printStackTrace();
          }
      }
    
      public static byte[] encrypt(byte[] content) {
          try {
              byte[] result = encryptCipher.doFinal(content);
              return result;
          } catch (IllegalBlockSizeException e) {
              e.printStackTrace();
          } catch (BadPaddingException e) {
              e.printStackTrace();
          }
          return null;
      }
    
      public static byte[] decrypt(byte[] content) {
          try {
              byte[] result = decryptCipher.doFinal(content);
              return result;
          } catch (IllegalBlockSizeException e) {
              e.printStackTrace();
          } catch (BadPaddingException e) {
              e.printStackTrace();
          }
          return null;
        }
    }
    
  • 解压和压缩

public class Zip {

    private static void deleteFile(File file){
        if (file.isDirectory()){
            File[] files = file.listFiles();
            for (File f: files) {
                deleteFile(f);
            }
        }else{
            file.delete();
        }
    }

    /**
     * 解压zip文件至dir目录
     * @param zip
     * @param dir
     */
    public static void unZip(File zip, File dir) {
        try {
            deleteFile(dir);
            ZipFile zipFile = new ZipFile(zip);
            //zip文件中每一个条目
            Enumeration<? extends ZipEntry> entries = zipFile.entries();
            //遍历
            while (entries.hasMoreElements()) {
                ZipEntry zipEntry = entries.nextElement();
                //zip中 文件/目录名
                String name = zipEntry.getName();
                //原来的签名文件 不需要了
                if (name.equals("META-INF/CERT.RSA") || name.equals("META-INF/CERT.SF") || name
                        .equals("META-INF/MANIFEST.MF")) {
                    continue;
                }
                //空目录不管
                if (!zipEntry.isDirectory()) {
                    File file = new File(dir, name);
                    //创建目录
                    if (!file.getParentFile().exists()) {
                        file.getParentFile().mkdirs();
                    }
                    //写文件
                    FileOutputStream fos = new FileOutputStream(file);
                    InputStream is = zipFile.getInputStream(zipEntry);
                    byte[] buffer = new byte[2048];
                    int len;
                    while ((len = is.read(buffer)) != -1) {
                        fos.write(buffer, 0, len);
                    }
                    is.close();
                    fos.close();
                }
            }
            zipFile.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 压缩目录为zip
     * @param dir 待压缩目录
     * @param zip 输出的zip文件
     * @throws Exception
     */
    public static void zip(File dir, File zip) throws Exception {
        zip.delete();
        // 对输出文件做CRC32校验
        CheckedOutputStream cos = new CheckedOutputStream(new FileOutputStream(
                zip), new CRC32());
        ZipOutputStream zos = new ZipOutputStream(cos);
        //压缩
        compress(dir, zos, "");
        zos.flush();
        zos.close();
    }

    /**
     * 添加目录/文件 至zip中
     * @param srcFile 需要添加的目录/文件
     * @param zos   zip输出流
     * @param basePath  递归子目录时的完整目录 如 lib/x86
     * @throws Exception
     */
    private static void compress(File srcFile, ZipOutputStream zos,
                                 String basePath) throws Exception {
        if (srcFile.isDirectory()) {
            File[] files = srcFile.listFiles();
            for (File file : files) {
                // zip 递归添加目录中的文件
                compress(file, zos, basePath + srcFile.getName() + "/");
            }
        } else {
            compressFile(srcFile, zos, basePath);
        }
    }

    private static void compressFile(File file, ZipOutputStream zos, String dir)
            throws Exception {
        // temp/lib/x86/libdn_ssl.so
        String fullName = dir + file.getName();
        // 需要去掉temp
        String[] fileNames = fullName.split("/");
        //正确的文件目录名 (去掉了temp)
        StringBuffer sb = new StringBuffer();
        if (fileNames.length > 1){
            for (int i = 1;i<fileNames.length;++i){
                sb.append("/");
                sb.append(fileNames[i]);
            }
        }else{
            sb.append("/");
        }
        //添加一个zip条目
        ZipEntry entry = new ZipEntry(sb.substring(1));
        zos.putNextEntry(entry);
        //读取条目输出到zip中
        FileInputStream fis = new FileInputStream(file);
        int len;
        byte data[] = new byte[2048];
        while ((len = fis.read(data, 0, 2048)) != -1) {
            zos.write(data, 0, len);
        }
        fis.close();
        zos.closeEntry();
    }

}
  • 工具类


public class Utils {

    /**
     * 读取文件
     *
     * @param file
     * @return
     * @throws Exception
     */
    public static byte[] getBytes(File file) throws Exception {
        RandomAccessFile r = new RandomAccessFile(file, "r");
        byte[] buffer = new byte[(int) r.length()];
        r.readFully(buffer);
        r.close();
        return buffer;
    }

    /**
     * 反射获得 指定对象(当前-》父类-》父类...)中的 成员属性
     *
     * @param instance
     * @param name
     * @return
     * @throws NoSuchFieldException
     */
    public static Field findField(Object instance, String name) throws NoSuchFieldException {
        Class clazz = instance.getClass();
        //反射获得
        while (clazz != null) {
            try {
                Field field = clazz.getDeclaredField(name);
                //如果无法访问 设置为可访问
                if (!field.isAccessible()) {
                    field.setAccessible(true);
                }
                return field;
            } catch (NoSuchFieldException e) {
                //如果找不到往父类找
                clazz = clazz.getSuperclass();
            }
        }
        throw new NoSuchFieldException("Field " + name + " not found in " + instance.getClass());
    }


    /**
     * 反射获得 指定对象(当前-》父类-》父类...)中的 函数
     *
     * @param instance
     * @param name
     * @param parameterTypes
     * @return
     * @throws NoSuchMethodException
     */
    public static Method findMethod(Object instance, String name, Class... parameterTypes)
            throws NoSuchMethodException {
        Class clazz = instance.getClass();
        while (clazz != null) {
            try {
                Method method = clazz.getDeclaredMethod(name, parameterTypes);
                if (!method.isAccessible()) {
                    method.setAccessible(true);
                }
                return method;
            } catch (NoSuchMethodException e) {
                //如果找不到往父类找
                clazz = clazz.getSuperclass();
            }
        }
        throw new NoSuchMethodException("Method " + name + " with parameters " + Arrays.asList
                (parameterTypes) + " not found in " + instance.getClass());
    }

    // 所有文件md5总和
    private static String fileSum = "";

    /**
     *
     * @param file
     * @param suffix
     * @return
     */
    public static String traverseFolder(File file, String suffix) {

        if (file == null) {
            throw new NullPointerException("遍历路径为空路径或非法路径");
        }

        if (file.exists()) { //判断文件或目录是否存在

            File[] files = file.listFiles();

            if (files.length == 0) { // 文件夹为空
                return null;
            } else {
                for (File f : files) { // 遍历文件夹

                    if (f.isDirectory()) { // 判断是否是目录

                        if ((f.getName().endsWith(suffix))) { // 只小羊.dex 结尾的目录 则计算该目录下的文件的md5值

                            // 递归遍历
                            traverseFolder(f, suffix);
                        }

                    } else {
                        // 得到文件的md5值
                        String string = checkMd5(f);
                        // 将每个文件的md5值相加
                        fileSum += string;
                    }
                }
            }

        } else {
            return null; // 目录不存在
        }

        return fileSum; // 返回所有文件md5值字符串之和
    }

    /**
     * 计算文件md5值
     * 检验文件生成唯一的md5值 作用:检验文件是否已被修改
     *
     * @param file 需要检验的文件
     * @return 该文件的md5值
     */
    private static String checkMd5(File file) {

        // 若输入的参数不是一个文件 则抛出异常
        if (!file.isFile()) {
            throw new NumberFormatException("参数错误!请输入校准文件。");
        }

        // 定义相关变量
        FileInputStream fis = null;
        byte[] rb = null;
        DigestInputStream digestInputStream = null;
        try {
            fis = new FileInputStream(file);
            MessageDigest md5 = MessageDigest.getInstance("md5");
            digestInputStream = new DigestInputStream(fis, md5);
            byte[] buffer = new byte[4096];

            while (digestInputStream.read(buffer) > 0) ;

            md5 = digestInputStream.getMessageDigest();
            rb = md5.digest();

        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        } finally {
            try {
                fis.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < rb.length; i++) {
            String a = Integer.toHexString(0XFF & rb[i]);
            if (a.length() < 2) {
                a = '0' + a;
            }
            sb.append(a);
        }
        return sb.toString(); //得到md5值
    }
}

  • application

public class ProxyApplication extends Application {
    //定义好解密后的文件的存放路径
    private String app_name;
    private String app_version;

    /**
     * ActivityThread创建Application之后调用的第一个方法
     * 可以在这个方法中进行解密,同时把dex交给android去加载
     */
    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        //获取用户填入的metadata
        getMetaData();
        //得到当前加密了的APK文件
        File apkFile = new File(getApplicationInfo().sourceDir);
        //把apk解压   app_name+"_"+app_version目录中的内容需要boot权限才能用
        File versionDir = getDir(app_name + "_" + app_version, MODE_PRIVATE);
        File appDir = new File(versionDir, "app");
        File dexDir = new File(appDir, "dexDir");

        //得到我们需要的加载的Dex文件
        List<File> dexFiles = new ArrayList<>();
        //进行解密(最好做MD5文件校验)
        if (!dexDir.exists() || dexDir.listFiles().length == 0) {
            //把apk解压到appDir
            Zip.unZip(apkFile, appDir);
            //获取目录下的所有的文件
            File[] files = appDir.listFiles();
            for (File file : files) {
                String name = file.getName();
                if (name.endsWith(".dex") && !TextUtils.equals(name, "classes.dex")) {

                    try {
                        AES.init(AES.DEFAULT_PWD);
                        //读取文件内容
                        byte[] bytes = Utils.getBytes(file);
                        //解密
                        byte[] decrypt = AES.decrypt(bytes);
                        //写到指定的目录
                        FileOutputStream fos = new FileOutputStream(file);
                        fos.write(decrypt);
                        fos.flush();
                        fos.close();
                        dexFiles.add(file);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }

                }
            }
        } else {
            for (File file : dexDir.listFiles()) {
                dexFiles.add(file);
            }
        }

        try {
            //2.把解密后的文件加载到系统
            loadDex(dexFiles, versionDir);
        } catch (Exception e) {
            e.printStackTrace();
        }

    }

    private void loadDex(List<File> dexFiles, File versionDir) {


        try {
            //1.获取pathlist
            Field   pathListField = Utils.findField(getClassLoader(), "pathList");
            Object  pathList = pathListField.get(getClassLoader());

            //2.获取数组dexElements
            Field dexElementsField=Utils.findField(pathList,"dexElements");
            Object[] dexElements=(Object[])dexElementsField.get(pathList);
            //3.反射到初始化dexElements的方法
            Method makeDexElements=Utils.findMethod(pathList,"makePathElements",List.class,File.class,List.class);

            ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
            Object[] addElements=(Object[])makeDexElements.invoke(pathList,dexFiles,versionDir,suppressedExceptions);

            //合并数组
            Object[] newElements= (Object[])Array.newInstance(dexElements.getClass().getComponentType(),dexElements.length+addElements.length);
            System.arraycopy(dexElements,0,newElements,0,dexElements.length);
            System.arraycopy(addElements,0,newElements,dexElements.length,addElements.length);

            //替换classloader中的element数组
            dexElementsField.set(pathList,newElements);
        } catch (Exception e) {
            e.printStackTrace();
        }

    }


    private void getMetaData() {
        try {
            ApplicationInfo applicationInfo = getPackageManager().getApplicationInfo(
                    getPackageName(), PackageManager.GET_META_DATA);
            Bundle metaData = applicationInfo.metaData;
            if (null != metaData) {
                if (metaData.containsKey("app_name")) {
                    app_name = metaData.getString("app_name");
                }
                if (metaData.containsKey("app_version")) {
                    app_version = metaData.getString("app_version");
                }
            }

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

加固工具

  • 加密算法、解压和压缩和工具类 上面的一样,这里就不贴代码了
public class Main {

    public static void main(String[] args) throws Exception {
        /**
         * 1.制作只包含解密代码的dex文件
         */
        File aarFile = new File("proxy_core/build/outputs/aar/proxy_core-debug.aar");
        File aarTemp = new File("proxy_tools/temp");
        Zip.unZip(aarFile,aarTemp);
        File classesJar = new File(aarTemp, "classes.jar");
        File classesDex = new File(aarTemp, "classes.dex");
//
//        //dx --dex --output out.dex in.jar
        Process process = Runtime.getRuntime().exec("cmd /c dx --dex --output " + classesDex.getAbsolutePath()
                + " " + classesJar.getAbsolutePath());
        process.waitFor();
        if (process.exitValue() != 0) {
            throw new RuntimeException("dex error");
        }

        /**
         * 2.加密APK中所有的dex文件
         */
        File apkFile=new File("app/build/outputs/apk/debug/app-debug.apk");
        File apkTemp = new File("app/build/outputs/apk/debug/temp");
        //解压
        Zip.unZip(apkFile, apkTemp);
        //只要dex文件拿出来加密
        File[] dexFiles = apkTemp.listFiles(new FilenameFilter() {
            @Override
            public boolean accept(File file, String s) {
                return s.endsWith(".dex");
            }
        });
        //AES加密了
        AES.init(AES.DEFAULT_PWD);
        for (File dexFile : dexFiles) {
            byte[] bytes = Utils.getBytes(dexFile);
            byte[] encrypt = AES.encrypt(bytes);
            FileOutputStream fos = new FileOutputStream(new File(apkTemp,
                    "secret-" + dexFile.getName()));
            fos.write(encrypt);
            fos.flush();
            fos.close();
            dexFile.delete();
        }

        /**
         * 3.把dex放入apk解压目录,重新压成apk文件
         */
        classesDex.renameTo(new File(apkTemp,"classes.dex"));
        File unSignedApk = new File("app/build/outputs/apk/debug/app-unsigned.apk");
        Zip.zip(apkTemp,unSignedApk);
//
//
//        /**
//         * 4.对齐和签名
//         */
//        zipalign -v -p 4 my-app-unsigned.apk my-app-unsigned-aligned.apk
        File alignedApk = new File("app/build/outputs/apk/debug/app-unsigned-aligned.apk");
        process = Runtime.getRuntime().exec("cmd /c zipalign -v -p 4 " + unSignedApk.getAbsolutePath()
                + " " + alignedApk.getAbsolutePath());
        process.waitFor();
//        if(process.exitValue()!=0){
//            throw new RuntimeException("dex error");
//        }
//
//
////        apksigner sign --ks my-release-key.jks --out my-app-release.apk my-app-unsigned-aligned.apk
////        apksigner sign  --ks jks文件地址 --ks-key-alias 别名 --ks-pass pass:jsk密码 --key-pass pass:别名密码 --out  out.apk in.apk
        File signedApk = new File("app/build/outputs/apk/debug/app-signed-aligned.apk");
        File jks = new File("proxy_tools/proxy2.jks");
        process = Runtime.getRuntime().exec("cmd /c apksigner sign --ks " + jks.getAbsolutePath()
                + " --ks-key-alias 123 --ks-pass pass:123456 --key-pass pass:123456 --out "
                + signedApk.getAbsolutePath() + " " + alignedApk.getAbsolutePath());
        process.waitFor();
//        if(process.exitValue()!=0){
//            throw new RuntimeException("dex error");
//        }
        System.out.println("执行成功");

    }
}

GitHub代码

第三方的加固工具

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 201,468评论 5 473
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,620评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,427评论 0 334
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,160评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,197评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,334评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,775评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,444评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,628评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,459评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,508评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,210评论 3 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,767评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,850评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,076评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,627评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,196评论 2 341

推荐阅读更多精彩内容

  • 声明:原创文章,转载请备注来源:http://shuwoom.com/?p=360&preview=true 10...
    空同定翁阅读 2,247评论 0 6
  • Base64.java public final class Base64 { static private ...
    BUG弄潮儿阅读 777评论 0 0
  • Java字节码详解(二)字节码的运行过程 2018年10月23日 17:31:04 talex 阅读数 677 文...
    呵呵_9e25阅读 183评论 0 0
  • 山鸡哥来啦 素万独家冠名哦 看到素万小U和靠垫了吗 29738213
    94f1d6bd2d12阅读 198评论 0 0
  • 教育既要看过程更要看结果,当温和的过程没有效果时,我会改变策略,就像今晚,我们又回到了以前那个熟悉的不能再熟悉场面...
    九五自尊阅读 176评论 0 0