首页
社区
课程
招聘
[原创]APK自我保护方法
2013-12-28 21:41 108350

[原创]APK自我保护方法

2013-12-28 21:41
108350
APK 的自我保护
MindMac
2013/12/28


由于 Android 应用程序中的大部分代码使用 Java 语言编写,而 Java 语言又比较容易进
行逆向,所以 Android 应用程序的自我保护具有一定的意义。本文总结了 Android 中可以使
用的一些 APK 自我保护的技术,大部分都经过实际的代码测试。

Dex 文件结构

classes.dex 文件是 Android 系统运行于 Dalvik Virtual Machine 上的可执行文件,也是
Android 应用程序的核心所在,所以我们首先来看下 DEX 文件的结构,这样能够更好的理解
后续的分析,需要更加详细的信息,可以参考 Google 关于 Dex 的技术文档
从 Java 源文件(当然 Android 也支持 JNI 的调用方式)到生成 Dex 文件的基本映射关系
如图 1 所示,Java 源文件通过 Java 编译器生成 class 文件,再通过 dx 工具转换为 classes.dex
文件。Dex 文件从整体上来看是个索引的结构,类名、方法名、字段名等信息都存储在常量
池中,这样能够充分减少存储空间,一个 Dex 文件的基本结构如图 2 所示,相关结构声明定
义在 DexFile.h 中,在 AOSP 中的路径为/dalvik/libdex/DexFile.h。

图 1 Java 源文件生成 Dex 文件的映射关系

header: Dex 文件头,包含 magic 字段、adler32 校验值、SHA-1 哈希值、string_ids 的个数

图 2 Dex 文件基本结构

以及偏移地址等。Dex 文件头结构固定,占用 0x70 个字节,定义如下所示。

1. struct DexHeader {

2. u1 magic[8]; /* includes version number */

3. u4 checksum; /* adler32 checksum */

4. u1 signature[kSHA1DigestLen]; /* SHA-1 hash */

5. u4 fileSize; /* length of entire file */

6. u4 headerSize; /* offset to start of next section */

7. u4 endianTag;

8. u4 linkSize;

9. u4 linkOff;

10. u4 mapOff;

11. u4 stringIdsSize;

12. u4 stringIdsOff;

13. u4 typeIdsSize;

14. u4 typeIdsOff;

15. u4 protoIdsSize;

16. u4 protoIdsOff;

17. u4 fieldIdsSize;

18. u4 fieldIdsOff;

19. u4 methodIdsSize;

20. u4 methodIdsOff;

21. u4 classDefsSize;

22. u4 classDefsOff;

23. u4 dataSize;

24. u4 dataOff;

25. };


DexStringId: 定义了字符串数据的偏移, stringDataOff 指向字符串数据;
1. struct DexStringId {
 
2. u4 stringDataOff; /* file offset to string_data_item */

3. };


DexTypeId: 表示应用程序代码中使用到的具体类型,如整型、字符串等,在 Dalvik 字节
码中表示为 I、Ljava/lang/String;,descriptorIdx 指向 DexStringId 列表的索引;
1. struct DexTypeId {

2. u4 descriptorIdx; /* index into stringIds list for type descriptor */

3. };


DexProtoId:表示方法声明的结构体,shortyIdx 是方法声明字符串,格式为返回值类型
后紧跟参数列表类型,如方法声明为 VI,表示返回值为 V(空,无返回值),参数为 I
(整型),所有的引用类型用 L 表示;returnTypeIdx 指向 DexTypeId 列表的索引,表示返
回值类型;parametersOff 指向 DexTypeList 的偏移,表示参数列表类型;
1. struct DexProtoId {
 
2. u2 classIdx; /* index into typeIds list for defining class */

3. u2 typeIdx; /* index into typeIds for field type */

4. u4 nameIdx; /* index into stringIds for field name */

5. };


DexFieldId: 表示代码中的字段,classIdx 指向 DexTypeId 列表索引,表示字段所属的类;
typeIdx 表示字段类型,nameIdx 指向 DexStringId 列表索引,表示字段名;
1. struct DexFieldId {
 
2. u2 classIdx; /* index into typeIds list for defining class */

3. u2 typeIdx; /* index into typeIds for field type */

4. u4 nameIdx; /* index into stringIds for field name */

5. };


DexMethodId: 表示代码中使用的方法, classIdx 表示方法所属的类, protoIdx 指向
DexProtoId 列表索引,表示方法原型,nameIdx 表示方法名;
1. struct DexMethodId {

2. u2 classIdx; /* index into typeIds list for defining class */

3. u2 protoIdx; /* index into protoIds for method prototype */

4. u4 nameIdx; /* index into stringIds for method name */

5. };


DexClassDef: 该结构相对要复杂一些,定义了代码中的使用的类,以及相关的代码指令。
1. struct DexClassDef {

2. u4 classIdx; /* index into typeIds for this class */

3. u4 accessFlags;

4. u4 superclassIdx; /* index into typeIds for superclass */

5. u4 interfacesOff; /* file offset to DexTypeList */

6. u4 sourceFileIdx; /* index into stringIds for source file name */

7. u4 annotationsOff; /* file offset to annotations_directory_item */

8. u4 classDataOff; /* file offset to class_data_item */

9. u4 staticValuesOff; /* file offset to DexEncodedArray */

10. };


classIdx 指向 DexTypeId 列表索引,表示该类的类型;accessFlags 是类的访问标志,如
public,private,static 等;superclassIdx 表示父类的类型;interfacesOff 指向一个 DexTypeList
的偏移值,因为 Java 中可以实现多个接口,这里使用列表也就不难理解了;sourceFileIdx
指向 DexStringIdx 列表的索引,表示类所在的源文件名称;annotationsOff 指向注解目录
结构;classDataOff 指向 DexClassData 结构,表示类的数据部分;staticValuesOff 表示类
中的静态数据。

DexClassData 结构体定义在 DexClass.h 文件中,路径为/dalvik/libdex/DexClass.h,声明如
下,header 中包含静态字段个数,实例字段个数,直接方法(通过类直接访问的方法)
个数,虚方法(通过类实例访问的方法)个数;
1. struct DexClassData {

2. DexClassDataHeader header;

3. DexField* staticFields;

4. DexField* instanceFields;

5. DexMethod* directMethods;

6. DexMethod* virtualMethods;

7. };


DexField 表示字段的类型和访问标志, fieldIdx 指向 DexFieldId;
1. struct DexField {

2. u4 fieldIdx; /* index to a field_id_item */

3. u4 accessFlags;

4. };


DexMethod 结构描述了方法的原型、名称、访问标志以及代码指令的偏移地址,methodIdx
指向 DexMethodId 索引,需要注意的是在 Google 的 Dex 文件文档中对此的定义:
index into the method_ids list for the identity of this method (includes the name and descriptor),
represented as a difference from the index of previous element in the list. The index of the first
element in a list is represented directly.


注意红色字体部分,表示的是在 Dex 文件中,methodIdx 是相对于前一个 DexMethod 中
的 methodIdx 的增量,例如如果一个类中有两个 directMethods,第一个 directMethod 的
methodIdx 值为 0x13,表示指向索引为 0x13 的 methodIdx,那么第二个 directMethod 的
methodIdx 的值是相对于前一个值的增量,例如 0x01,表示指向索引为 0x14 的 methodIdx;
accessFlags 为方法的访问标志,codeOff 表示指令代码的偏移地址;
1. struct DexMethod {

2. u4 methodIdx; /* index to a method_id_item */

3. u4 accessFlags;

4. u4 codeOff; /* file offset to a code_item */

5. };


DexCode 的结构体声明如下。
1. struct DexCode {

2. u2 registersSize;

3. u2 insSize;

4. u2 outsSize;

5. u2 triesSize;

6. u4 debugInfoOff; /* file offset to debug info stream */

7. u4 insnsSize; /* size of the insns array, in u2 units */

8. u2 insns[1];

9. /* followed by optional u2 padding */

10. /* followed by try_item[triesSize] */

11. /* followed by uleb128 handlersSize */

12. /* followed by catch_handler_item[handlersSize] */

13. };


需要注意的是,在 DexClass.h 中,所有的 u4 类型,实际上是 uleb128 类型。每个 uleb128
类型是 leb128 的无符号类型,每个 leb128 类型的数据包含 1-5 个字节,表示一个 32bit
的数值。每个字节只有 7 位有效,最高一位用来表示是否需要使用到下一个字节,比如
如果第一个字节最高位为 1,表示还需要使用到第 2 个字节,如果第二个字节的最高位
为 1,表示会使用到第 3 个字节,以此类推,最多 5 个字节。对于一个 2 个字节的 leb128
类型数据,其结构如图 3 所示。

图 3 两字节的 leb128 类型数据格式

Dex 中方法的隐藏

此部分内容可参考 Playing Hide and Seek with Dalvik Executables

前文分析了 Dex 文件的结构,根据 Dex 的文件结构,可以实现对 Dex 中特定方法的隐藏,
这样在使用 baksamli 或者 apktool 工具对 classes.dex 文件进行反汇编时,无法发现隐藏的方
法,不过会有特定的现象发生,其实也是比较容易检测出来的。

在 Dex 文件格式分析中关于 method 的结构体是 DexMethod,如果将 methodIdx 的值指向
另一个 method,同时修改相应的代码偏移量 codeOff(accessFlags 一般不需要修改),修改
后续相应的 methodIdx,则可以实现特定方法的隐藏。对 Dex 文件修改后需要重新计算 Dex
文件的 SHA1 值以及校验值,用来更新 Dex 文件。

隐藏方法的步骤如下:
修改 Dex 文件中需要隐藏方法的 DexMethod 结构体,如图 4 所示,图中隐藏了方法
B。具体包括:

[*]将 DexMethod 的 methodIdx 值设为 0x0,相当于将原先的方法指向了前一个方
法;
[*]访问标志符 accessFlags 一般不需要修改,在 Dex 文件格式里,directMethods
和 virtualMethods 是分开的;
[*]将 codeOffset 设置为前一个方法的代码偏移地址。
[*]更新需隐藏方法的下一个方法的 methodIdx,可以使用公式:
next_method_idx=original_next_method_idx + original_method_idx



[*]重新计算 Dex 的 SHA1 哈希值和 Adler 校验值,并用以更新 DexHeader,可以使用
DexFixer 修复 classes.dex 文件;
[*]重新打包生成 APK 文件:

[*]将 APK 解压缩,提取其中出 META-INF 文件夹之外的所有文件;
[*]压缩成 Zip 格式文件;
[*]使用 jarsigner 或者其他工具对生成的 Zip 文件签名,后缀名修改成.apk。


隐藏的方法仍然需要在程序中进行调用,调用隐藏方法的步骤如下:

[*]使用反射调用 android.content.res.AssetManager.openNonAsset 方法打开当前应用程
序 的 classes.dex 文 件 , 将 数 据 保 存 到 内 存 中 ; 还 可 以 通 过 调 用
Context.getPackageCodePath()来获得当前应用程序对应的 apk 文件的路径,利用此
路 径 构 造 ZipFile 对 象 , 进 而 获 取 classes.dex 的 ZipEntry , 利 用 ZipFile 的
getInputStream(ZipEntry)方法获取 classes.dex 的数据流,核心代码如下所示;

1. String apkPath = this.getPackageCodePath();

2. ZipFile apkfile = new ZipFile(apkPath);

3. ZipEntry dexentry = zipfile.getEntry("classes.dex");

4. InputStream dexstream = zipfile.getInputStream(dexentry);



[*]修复 Dex 文件,将之前隐藏方法的 DexMethod 结构体恢复;
[*]将修复后的 Dex 数据使用类加载器重新加载;
[*]搜索被隐藏的方法;
[*]调用被隐藏的方法。


需要注意的是,方法在 Dex 文件中是按方法名的字典序排序的,所以需要隐藏的方法如
果是该类中所有方法排序第一个的话,那么 methodIdx 值是个绝对值,如果要隐藏的话就不
是很方便,所以建议可以写个无用的方法,其方法名排序为第一个,让需要隐藏的方法重新
指向该方法。

使用修改 methodIdx 的方法,让其指向另一个 DexMethodId 的结构体,如果使用 baksmali
进行反汇编,则会发现在一个类中有两个完全相同的函数。

那有没有更加隐蔽的手段来隐藏一个方法了?考虑到在 DexClassData 结构体中的
DexClassDataHeader 头部,其中 directMethodsSize 和 virtualMethodsSize 分别表示直接方法个
数和虚方法个数,因此如果希望隐藏某个方法,可以通过将相应的 directMethodsSize 或
virtualMethodsSize 减 1,同时将表示该需要隐藏方法的 DexMethod 结构体中的数据全部修改
为 0,这样就可以将该方法隐藏起来,使用 baksmali 反汇编时,不会显示出该方法的反汇编
代码,具体可以参考 Hashdays 2012 Android Chanllenge

当然,上述这两种隐藏方法,都没能隐藏掉 DexMethodId 结构体,这个结构体中包含了
方法所属的类名、原型声明以及方法名,所以可以通过对比 DexMethodId 的个数和 DexMethod
结构体的个数来判断是否存在方法隐藏的问题。

Dex 完整性校验

classes.dex 在 Android 系统上基本负责完成所有的逻辑业务,因此很多针对 Android 应用
程序的篡改都是针对 classes.dex 文件的。在 APK 的自我保护上,也可以考虑对 classes.dex
文件进行完整性校验,简单的可以通过 CRC 校验完成,也可以检查 Hash 值。由于只是检查
classes.dex,所以可以将 CRC 值存储在 string 资源文件中,当然也可以放在自己的服务器上,
通过运行时从服务器获取校验值。基本步骤如下:


[*]首先在代码中完成校验值比对的逻辑,此部分代码后续不能再改变,否则 CRC 值
会发生变化;
[*]从生成的 APK 文件中提取出 classes.dex 文件,计算其 CRC 值,其他 hash 值类似;
[*]将计算出的值放入 strings.xml 文件中。

核心代码如下:
1. String apkPath = this.getPackageCodePath();

2. Long dexCrc = Long.parseLong(this.getString(R.string.dex_crc));

3. try {

4. ZipFile zipfile = new ZipFile(apkPath);

5. ZipEntry dexentry = zipfile.getEntry("classes.dex");

6. if(dexentry.getCrc() != dexCrc){

7. System.out.println("Dex has been *modified!");

8. }else{

9. System.out.println("Dex hasn't been modified!");

10. }

11. } catch (IOException e) {

12. // TODO Auto-generated catch block

13. e.printStackTrace();

14. }


但是上述的保护方式容易被暴力破解, 完整性检查最终还是通过返回 true/false 来控制
后续代码逻辑的走向,如果攻击者直接修改代码逻辑,完整性检查始终返回 true,那这种方
法就无效了,所以类似文件完整性校验需要配合一些其他方法,或者有其他更为巧妙的方式
实现?

APK 完整性校验

虽然 Android 程序的主要逻辑通过 classes.dex 文件执行,但是其他文件也会影响到整个
程序的逻辑走向,以上述 Dex 文件校验为例,如果程序依赖 strings.xml 文件中的某些值,则
修改这些值就会影响程序的运行,所以进一步可以整个 APK 文件进行完整性校验。但是如
果对整个 APK 文件进行完整性校验,由于在开发 Android 应用程序时,无法知道完整 APK 文
件的 Hash 值,所以这个 Hash 值的存储无法像 Dex 完整性校验那样放在 strings.xml 文件中,
所以可以考虑将值放在服务器端。核心代码如下:
1. MessageDigest msgDigest = null;

2. try {

3. msgDigest = MessageDigest.getInstance("MD5")

4. byte[] bytes = new byte[8192];

5. int byteCount;

6. FileInputStream fis = null;

7. fis = new FileInputStream(new File(apkPath));

8. while ((byteCount = fis.read(bytes)) > 0)

9. msgDigest.update(bytes, 0, byteCount);

10. BigInteger bi = new BigInteger(1, msgDigest.digest());

11. String md5 = bi.toString(16);

12. fis.close();

13. /*

14. 从服务器获取存储的 Hash 值,并进行比较

15. */
	
16. } catch (Exception e) {

17. e.printStackTrace();

18. }


Java 反射

Android 应用程序开发主要使用 Java 语言,Java 中可以使用反射技术来更加灵活地控制
程序的运行,为 Java 运行时的行为提供了强大的支持。Java 反射机制允许运行中的 Java 程
序对自身进行检查,并能直接操作程序的内部属性或方法,可动态生成类实例、变更属性内
容以及调用方法。关于 Java 反射更详细内容可以参考 Java programming dynamics, Part 2:
Introducing reflection


在 Android 中使用反射技术来动态调用方法,可以增加对应用程序进行静态分析的难度。
以下代码是使用 Java 反射的一个简单例子,需要使用反射调用的方法存在于 Reflection 类中。
1. public class Reflection {

2. public void methodA(){

3. System.out.println("Invoke methodA");

4. }

5. public void methodB(){

6. System.out.println("Invoke methodB");

7. }

8. }


以下代码完成对 Reflection 类中方法的直接调用和反射调用。
1. protected void onCreate(Bundle savedInstanceState) {

2. ......

3. Reflection reflection = new Reflection();

4. reflection.methodA();

5. reflection.methodB();

6. 

7. Class[] consTypes = new Class[]{};

8. Class reflectionCls = null;

9. String className = "com.example.reflection.Reflection";

10. String methodName = "methodA";

11. try {

12. reflectionCls = Class.forName(className);

13. Constructor cons = reflectionCls.getConstructor(consTypes);

14. Reflection reflectionIns = (Reflection) cons.newInstance(new Object[]{});

15. Method method = reflectionCls.getDeclaredMethod(methodName, new Class[]{});

16. method.invoke(reflectionIns, new Object[]{});

17. } catch (Exception e) {

18. // TODO Auto-generated catch block

19. e.printStackTrace();

20.    }

21. }



当然以上 Java 反射的例子过于简单,使用 dex2jar 反编译后,用 jd-gui 打开,还是能够很容
易的识别出需要调用的方法,如图 5 所示。

图 5 使用 dex2jar+jd-gui 反编译结果

所以需要进一步采取措施增加静态分析的难度。反射调用需要获取调用的类名和方法名,而
上述代码将需要调用的类名或方法硬编码在代码中,一方面违背了 Java 反射使用的场景,
Java 反射主要是为了提供程序的运行时动态行为的控制,另一方面并没有增加了静态分析
的难度。

可以根据程序运行过程中的实时状态来调用相应的方法,从而进一步提高静态分析的难
度。一个可能的应用场景是:根据当前应用程序的状态,从网络服务器获取需要进行反射调
用的方法以及参数信息。例如对于上述例子,类名和方法名都可以从网络获取。这样做的好
处是使得仅仅通过静态分析无法获知程序运行过程中实际调用的方法,也会增加自动化分析
的难度。也可以使用反射加密的方式,将类名、方法名做加密处理,在实际调用时再进行解
密。当然以上两种处理方式可能对性能有较大影响(本身 Java 反射对性能就有一定影响),
不应该频繁使用,而且必须申请网络连接的权限(不过现在凡是个 Android 应用程序,不申
请个网络连接权限都不好意思说自己是个 Android 应用)同时还得需要接入网络。

动态加载

Android 系统提供了 DexClassLoader 来支持在程序运行过程中动态加载包含 classes.dex
的.jar 或者.apk 文件,如果再结合 Java 反射技术,可以实现执行非应用程序部分的代码。利
用动态加载技术,可以提供逆向分析的难度,在一定程度上可以保护 APK 自身的业务逻辑
防止被破解。

DexClassLoader 的构造函数原型如下:
1. public DexClassLoader (String dexPath, String optimizedDirectory, String
libraryPath, ClassLoader parent)


其中,dexPath 为包含 dex 文件的.apk 或者.jar 路径,optimizedDirectory 是优化后的 dex 文件
的路径,libraryPath 表示 Native 库的路径,parent 是父类加载器。通过 DexClassLoader 实例
化对象,调用 loadClass 加载需要调用的类,获得 Class 对象后,就可以进一步使用 Java 反
射技术来调用相应的方法。如下:
1. DexClassLoader classLoader = new DexClassLoader(apkPath, dexPath, null,

getClassLoader());

2. try {

3. Class<?> mLoadClass =

classLoader.loadClass("com.example.dexclassloaderslave.DexSlave");

4. Constructor<?> constructor = mLoadClass.getConstructor(new Class[] {});

5. Object dexSlave = constructor.newInstance(new Object[] {});

6. Method sayHello = mLoadClass.getDeclaredMethod("sayHello", new Class[]{} );

7. sayHello.setAccessible(true);

8. sayHello.invoke(dexSlave, new Object[]{});

9. } catch (Exception e)

10. {
 
11. e.printStackTrace();
 
12. }


上述代码实现调用 com.example.dexclassloaderslave.DexSlave 类中的 sayHello 方法。

对于需要通过 DexClassLoader 被调用的.apk 或者.jar 文件的分发,可以将其放入 Android
项目的 assets 或者 res 目录下,也可以将其放在服务器端,在实际需要调用时通过网络获取
文件。为了提高逆向的难度,可以对被调用的.apk 或者.jar 文件采取以下措施进行进一步的
保护:


[*]进行完整性校验,防止文件被篡改;
[*]进行加密处理,在调用加载前进行解密;
[*]对需要调用的函数相关信息使用通过网络获取的方式,而不是硬编码在代码中,可
以真正实现动态调用,提高静态分析的难度;
[*]对于使用网络服务器分发的方式,注意对网络服务器地址的保护,不要以字符串硬
编码的方式写在代码中,对下载请求也需要使用 cookie 等辅助识别的技术。


除了使用 DexClassLoader 类实现动态加载外,还可以使用 dalvik.system.DexFile 类实现
Dex 文件的加载,但是 DexFile 类提供的构造方法在实例化过程中需要在/data/davik-cache 目
录下生成相应的 Dex 文件,而/data/davik-cache 目录对于一般应用程序是没有写权限的,所
以在程序中无法实例化 DexFile 对象,也就无法调用 DexFile.loadClass 方法。所以需要通过反
射调用 DexFile 类的 openDex 方法,具体可以参考该代码中 invokeHidden 函数。

字符串处理

Android 应用程序开发中难免会使用到字符串,如服务器的地址等一些敏感信息,对于
这些字符串如果使用硬编码的方式,容易通过静态分析获取,甚至可以使用自动化分析工具
批量提取。例如若在 Java 源代码中定义一个字符串如下:
1. String str = "I am a string!";


则在反编译的.smali 代码中对应的代码如下(寄存器可能会有区别):
1. const-string v0, "I am a string!"


对于自动化分析工具,只需要扫描到 const-string 关键字就可以提取到字符串值。因此应该
尽量避免在源代码中定义字符串常量,比较简单的做法可以使用 StringBuilder 类通过 append
方法来构造需要的字符串,或者使用数组的方式来存储字符串。使用 StringBuilder 构造字符
串反编译后的代码如下,使用这种方式可以增加自动化分析的难度,如果想要完整提取一个
字符串,如果仅仅采用静态分析方法就必须要进行相应的词法语法解析了。
1. .line 26

2. .local v10, strBuilder:Ljava/lang/StringBuilder;

3. const-string v11, "I"

4. invoke-virtual {v10, v11},

 Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

5. .line 27

6. const-string v11, "am"

7. invoke-virtual {v10, v11},

Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

8. .line 28

9. const-string v11, "a"

10. invoke-virtual {v10, v11},

Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

11. .line 29

12. const-string v11, "String"

13. invoke-virtual {v10, v11},

Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

14. .line 30

15. invoke-virtual {v10}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;


另外也可以对字符串进行加密处理,很多恶意代码就采用了此种方法,例如一些具有
bot 功能的恶意代码会将 C&C 服务器地址以及命令进行加密处理,运行时再进行解密。

代码乱序

为了增加逆向分析的难度,可以将原有代码在 smali 格式上进行乱序处理同时又不会影
响程序的正常运行。乱序的基本原理如图 6 所示,将指令重新布局,并给每块指令赋予一个
label,在函数开头处使用 goto 跳到原先的第一条指令处,然后第一条指令处理完,再跳到
第二条指令,以此类推。

图 6 代码乱序的基本原理

以两个整数相加为例,Java 代码如下所示:
1. public void test(){

2. int a = 1;

3. int b = 2;

4. int c = a + b;

5. System.out.println(c);

6. }


反编译后的 smali 代码如下所示
1. .method public test()V

2. .locals 4

3.

4. .prologue

5. .line 24

6. const/4 v0, 0x1

7.

8. .line 25

9. .local v0, a:I

10. const/4 v1, 0x2

11.

12. .line 26

13. .local v1, b:I

14. add-int v2, v0, v1

15.

16. .line 27

17. .local v2, c:I

18. sget-object v3, Ljava/lang/System;->out:Ljava/io/PrintStream;

19.

20. invoke-virtual {v3, v2}, Ljava/io/PrintStream;->println(I)V

21.

22. .line 28

23. return-void


我们可以根据上述提到的代码乱序原理,将 test 这个函数乱序成如下代码所示(删除了.line):
1. .method public test()V

2. .locals 4

3.

4. .local v2, c:I

5. goto :lab1

6. :lab3

7. sget-object v3, Ljava/lang/System;->out:Ljava/io/PrintStream;

8. invoke-virtual {v3, v2}, Ljava/io/PrintStream;->println(I)V

9. goto :end

10. 

11. .local v1, b:I

12. :lab2

13. add-int v2, v0, v1

14. goto :lab3

15. 

16. .local v0, a:I

17. :lab1

18. const/4 v0, 0x1

19. const/4 v1, 0x2

20. goto :lab2

21. 

22. :end

23. return-void
 
24. .end method


最后使用 apktool 重新打包发布。进行代码乱序可以在一定程度上增加逆向分析的难度,例
如可以使用 dex2jar+jd-GUI 工具来分析上述乱序前后的代码。乱序前代码如图 7 所示:

图 7 使用 jd-GUI 分析乱序前代码

乱序后的代码如图 8 所示。从乱序前后的代码可以看出,使用代码乱序技术能够在一定程
度上增加逆向分析的难度,当然这是因为 dex2jar 工具在进行代码解析时的问题,如果能够
针对性的处理这种代码乱序的情况,那么这种反编译的情况应该会有所好转。
关于代码乱序的技术,可以参考 ADAM:An automatic and extensible platform to stress test android anti-virus systems 、 DroidChameleon:Evaluating  Android Anti-malware against Transformation Attacks

图 8 使用 jd-GUI 分析乱序后代码

模拟器检测

般在分析 APK 的过程中会借助于 Android 模拟器,比如分析网络行为,动态调试等。
因此从 APK 自我保护的角度出发,可以增加对 APK 当前运行环境的检测,判断是否运行在
模拟器中,如果运行在模拟器中可以选择退出整个应用程序的执行或者跳到其他分支。模拟
器检测的手段有很多,下面逐一分析。

1.属性检测

Android 属性系统类似于 Windows 的注册表机制,所有的进程可以共享系统设置值。关
于 Android 属 性 系 统 的 详 细 原 理 , 可 以 参 考 我 对 于 Android 属 性 系 统 的 分 析 文 章
http://www.kanxue.com/bbs/showthread.php?t=182901。一些属性值在 Android 模拟器和真机上
是不同的,例如对于 Nexus4 和 SDK 为 4.1.2 的模拟器来说,Build.BRAND 和 Build.DEVICE 属
性值分别如图 9 和图 10 所示。根据这些属性值在真实机器和模拟器上的差别可以比较容易


图 9 Nexus4 的 BRAND 和 DEVICE 值

图 10 Android 模拟器的 BRAND 和 DEVICE 值

检测 Android 应用程序是否运行在模拟器中。不过对于这种检测方式,绕过也是比较容易的,
我现在想到的有 3 种方式绕过:

[*]在源码中修改相应的属性值,重新编译生成内核等镜像文件,再使用这些重新生成
的镜像文件加载模拟器(对于 BRAND 属性值可以修改/build/target/product/generic.mk
文件中的 PRODUCT_BRAND 值,重新编译过程没测试,是不是只需要修改这个值就
能搞定不一定正确,可以参考下 build.prop 生成过程分析);
[*]修改 boot.img 文件;
[*]使用 Xposed 框架,可以 hook SystemProperties.get 函数,在 before 函数中检查需要
获取的属性,根据情况修改对应的值,然后返回;
另外还可以通过检测 IMEI,IMSI 等值来判断是否是模拟器,在模拟器中,这两个值默认
分别是 000000000000000 和 310260000000000。通过以下代码可以获取 IMSI 值:

1. TelephonyManager manager = (TelephonyManager)getSystemService(TELEPHONY_SERVICE);

2. String imsi = manager.getSubscriberId();


TELEPHONY_SERVICE 需要申请 android.permission.READ_PHONE_STATE 权限。同样我们可以有
相应的绕过方式,一个相对简单的方法是直接修改 Android SDK 下/tools/emulator-arm.exe 文
件(Windows 版本)。使用 010Editor 打开 emulator-arm.exe 文件,搜索 CIMI,如图 11,”CIMI.”
后面的 15 位数字值是 IMSI,”CGSN.”后面的 15 位数字为 IMEI,修改这两个值(确保没有运行
模拟器),然后保存。

图 11 修改 IMSI 和 IMEI

修改后再运行模拟器,此时查看 IMSI 值如所示,IMEI 值如所示,可见可以成功修改这两个
值。

图 12 修改后的 IMSI 值


图 13 修改后的 IMEI 值

还存在其他的修改方式,可以参考 Hide the Emulator以及 Android emulator patch for configurable
IMEI, IMSI and SIM card serial number

当然还可以检查一些其他的值,如电池的电池状态、电池电量,Secure.ANDROID_ID,
DeviceId,手机号码等。

2.虚拟机文件检测

相 对 于 真 实 设 备 , Android 模 拟 器 中 存 在 一 些 特 殊 的 文 件 或 者 目 录 , 如
/system/bin/qemu-props,该可执行文件可以用来在模拟器中设置系统属性。另外还有
/system/lib/libc_malloc_debug_qemu.so 文件以及/sys/qemu_trace 目录。我们可以通过检测这些
特殊文件或者目录是否存在来判断 Android 应用程序是否运行在模拟器中,关键代码如下:
1. private static String[] known_files = {

2. "/system/lib/libc_malloc_debug_qemu.so",

3. "/sys/qemu_trace",

4. "/system/bin/qemu-props"
 
5. };

6.

7. public static boolean hasQEmuFiles() {

8. for(String pipe : known_files) {

9. File qemu_file = new File(pipe);

10. if (qemu_file.exists())

11. return true;

12. }

13. return false;

14. }


更完整的代码可以参考 Tim Strazzere 的 Github 中 anti-emulator,该项目中还列举了其他一些
模拟器检测的方法,如检测 socket 文件/dec/socket/qemud。

3.基于 Cache 行为的模拟器检测方法

BlueBox 关于 Android 模拟器检测的方法
http://bluebox.com/corporate-blog/android-emulator-detection/

4.基于代码指令执行的模拟器检测方法

DexLabs 关于 Android 模拟器检测的方法
http://dexlabs.org/blog/btdetect

5.其他方法

其他一些检测方法,可以参考如下文献:

[*]DISSECTING THE ANDROID BOUNCER
[*]逃离安卓动态检测
[*]Guns and Smoke to Defeat Mobile Malware
[*]DEX EDUCATION 201 ANTI-EMULATION
[*]INSECURE MAGZINE 34 – Introduction to Android malware analysis


APK 伪加密

APK 实际上是 Zip 压缩文件,但是 Android 系统在解析 APK 文件时,和传统的解压缩软
件在解析 Zip 文件时还是有所差异的,利用这种差异可以实现给 APK 文件加密的功能。Zip
文件格式可以参考 MasterKey 漏洞分析的一篇文章。在 Central Directory 部分的 File Header 头
文件中,有一个 2 字节长的名为 General purpose bit flags 的字段,这个字段中每一位的作用
可以参考 Zip 文件格式规范的 4.4.4 部分,其中如果第 0 位置 1,则表示 Zip 文件的该 Central
Directory 是加密的,如果使用传统的解压缩软件打开这个 Zip 文件,在解压该部分 Central
Directory 文件时,是需要输入密码的,如图 14 所示。但是 Android 系统在解析 Zip 文件时并
没有使用这一位,也就是说这一位是否置位对 APK 文件在 Android 系统的运行没有任何影响。
一般在逆向 APK 文件时,会首先使用 apktool 来完成资源文件的解析,dex 文件的反汇编工
作,但如果将 Zip 文件中 Central Directory 的 General purpose bit flags 第 0 位置 1 的话,
apktool(version:1.5.2)将无法完成正常的解析工作,如图 15 所示,但是又不会影响到 APK 在
Android 系统上的正常运行,如图 16 所示。

图 14 传统解压缩软件需要输入密码进行解压缩


图 15 apktool 解析伪加密的 APK 文件失败

对 APK 文件进行伪加密可以使用这个脚本,在 Python 的 zipfile 模块中,ZipInfo 类中记
录了 Zip 文件中相应的 Central Directory 的相关信息,包括 General purpose bit flags,在 ZipInfo
类中属性为 flag_bits,因此上述脚本中将需加密的 APK 文件的每个 ZipInfo 的 flag_bits 和 1 做
或操作,实现在 General purpose bit flags 的第 0 位置 1.
而需要去除这些伪加密的标志的话,可以使用这个脚本。相关内容可以参考 BlueBox 之
前提出的一个 Android Security Analysis Chanllenge.

图 16 伪加密的 APK 可以正常运行

1. unsigned int gpbf = get2LE(lfhBuf + kLFHGPBFlags);

2. if ((gpbf & kGPFUnsupportedMask) != 0) {

3. ALOGW("Invalid General Purpose Bit Flag: %d", gpbf);

4. return false;

5. }


Manifest Cheating

AndroidManifest.xml 是 Android 应用程序的配置文件,包含了包名、应用程序名称、申请
的权限信息以及组件信息等。在 Android 应用程序开发,生成 APK 时,aapt 会负责完成资源
的打包,打包会将文本格式的 XML 资源文件编译成二进制格式的 XML 资源文件。将文本格
式的 XML 文件转换成二进制格式,一方面通过字符串资源池的统一管理,减少文件体积;
另一方面二进制格式的 XML 文件解析速度也会更快。在 Android 开发过程中,生成的 R.java
文件中包含了相应的资源类型、名称以及对应的 id 值。资源 id 是 32bit 的整型值,格式
为:0xPPTTNNNN。其中 PP 表示使用该资源的包,TT 代表该资源的类型,而 NNNN 是该类型
中资源的名称。对于应用程序资源,PP 值固定为 7f,而对于被引用的系统资源包,其 PP
值为 01。TT 和 NNNN 一般是 aapt 按照资源出现的顺序生成的。更多分析可以参考罗升阳的
Android 应用程序资源的编译和打包过程分析

Manifest Cheating 的基本原理是,在 AndroidManifest 的<application>节点中插入一个未知
id(如 0x0),名称为 name 的属性,其值可以是一个从未定义实现的 Java 类文件名。而对
AndroidManifest 的修改需要在二进制格式下进行,这样才能不会破坏之前 aapt 对资源文件的
处理。由于是未知的资源 id,在应用程序运行过程中,Android 会忽略此属性。但是在使用
apktool 进行重打包时,首先会将 AndroidManifest.xml 转换为明文,进而会包含名称为 name
的属性,而相应的 id 信息会丢失,apktool 重打包会重新进行资源打包处理,由于该 name
属性值是一个未实现的 Java 类,重打包后的应用程序在运行过程中,由于 application 节点
中定义的类是先于所有其他组件运行的,若系统找不到对应的类,会出现运行时错误,Dalvik
虚拟机会直接关闭。另外,也可以实现 name 属性值对应的 Java 类,若此类被调用,则表明
被重打包了,可以采取进一步的措施。这样就可以起到保护自身 APK 的作用,防止被重打
包。但是这种方法也很容易被绕过,只需要在经过 apktool 解码的 AndroidManifest 文件中,
去掉在 application 节点中添加的 name 属性即可。整个过程如下:

[*]将 APK 解压缩,提取其中的 AndroidManifest.xml 文件;
[*]使用 axml 工具,修改二进制的 AndroidManifest.xml 文件,在 application 节点下插入
id 未知(如 0x0),名为 name 的属性(值可以任意,只要不对应到项目中的类文件名
即可,如 some.class);
[*]将除 META-INF 文件夹之外的文件压缩成 zip 文件,签名后生成.apk 文件。
若是攻击者使用 apktool 重打包,运行重打包后的文件会出现如下运行时错误:


图 17 使用 Manifest Cheating 重打包后 APK 文件运行时错误

调试器检测

在对 APK 逆向分析时,往往会采取动态调试技术,可以使用 netbeans+apktool 对反汇编
生成的 smali 代码进行动态调试。为了防止 APK 被动态调试,可以检测是否有调试器连接。
Android 系统在 android.os.Debug 类中提供了 isDebuggerConnected()方法,用于检测是否有调
试器连接。可以在 Application 类中调用 isDebuggerConnected()方法,判断是否有调试器连接,
如果有,直接退出程序。

除了 isDebuggerConnected 方法,还可以通过在 AndroidManifest 文件的 application 节点中
加入 android:debuggable=”false”使得程序不可被调试,这样如果希望调试代码,则需要修改
该值为 true,因此可以在代码中检查这个属性的值,判断程序是否被修改过,代码如下:
1. if(getApplicationInfo().flags &= ApplicationInfo.FLAG_DEBUGGABLE != 0){
 
2. System.out.println("Debug");

3. android.os.Process.killProcess(android.os.Process.myPid());

4. }


代码混淆

使用 Java 编写的代码很容易被反编译,因此可以使用代码混淆的方法增加反编译代码
阅读的难度。ProGuard 是一款免费的 Java 代码混淆工具,提供了文件压缩、优化、混淆和
审核功能。在 Eclipse+ADT 开发环境下,每个 Android 应用程序项目目录下会默认生成
project.properties 和 proguard-project.txt 文件。如果需要使用 ProGuard 进行压缩以及混淆,首
先需要在 project.properties 文件中去掉对如下语句的注释:
1. proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-project.txt


ProGuard 的相关配置信息需要在 proguard-project.txt 文件中声明,在其中可以设置需要混淆
和保留的类或方法。由于在某些情况下,ProGuard 会错误地认为某些代码没有被使用,如在
只在 AndroidManifest 文件中引用的类,从 JNI 中调用的方法等。对于这些情况,需要在
proguard-project.txt 文件中添加-keep 命令,用来保留类或方法。关于 ProGuard 更加详细的配
置项可以参考 ProGuard Manual
除了使用 ProGuard 对 Android 代码进行混淆外,还可以使用 DexGuard。DexGuard 是特别
针对 Android 的一款代码优化混淆的收费软件,提供代码优化混淆、字符串加密、类加密、
Assets 资源加密、隐藏对敏感 API 的调用、篡改检测以及移除 Log 代码。DexGuard 的进一步
分析可以参考 JEB 上的相关 blog,可以在这里总结一下:

1.字符串加密
经过 DexGuard 加固过的 APK,对字符串的访问会通过调用一个解密函数来完成加密字
符串的解密。如图 18 所示,红框中的字节数组是加密后的字符串,在 onCreate 函数中,调
用了解密函数进行解密。字符解密函数如图 19 所示,对其进行处理后如图 20 所示,加密算
法也很简单,基本思路是:当前字符由前一个字符加上加密字符数组中的字符,再减去常量
8 形成,当字符长度达到给定的长度时,会最终构成字符串并返回。

图 18 DexGuard 字符串加密


图 19 字符串解密函数


图 20 处理后的解密函数

2.assets 加密
APK 文件的 assets 目录下包含了应用程序需要使用到的资源文件,DexGuard 提供了对
assets 资源文件的加密功能。对于一个经过保护的 asset 资源文件,例如 1.png 文件,使用
十六进制查看器查看该文件,如图 21 所示。从图中可见,加密后的 png 文件,缺失了相应
的文件头。解密则是首先通过反射调用 AssetManager.open 函数,同时对该函数的反射调用

图 21 经过加密的 png 文件

又使用了加密处理,最后通过 Cipher 类完成 png 文件的解密。相关解密处理如所示。

图 22 asset 解密处理

关于 ProGuard 和 DexGuard 还可以参考 ProGuard and DexGuard,其中除了介绍了 ProGuard
和 DexGuard,还提供了一些 APK 加固处理的方法。
关于代码混淆,还可以参考 Android:Game of Obfuscation

NDK

Android 软件的开发主要使用 Java 语言,但是 Android 也提供了对本地语言 C、C++的支
持。借助 JNI,可以在 Java 类中使用 C 语言库中的特定函数,或在 C 语言程序中使用 Java
类库。一般来说,如果代码中对处理速度有较高要求或者为了更好地控制硬件,抑或者为了
复用既有的 C/C++代码,都可以考虑通过 JNI 来实现对 Native 代码的调用。
由于逆向 Native 程序的汇编代码要比逆向 Java 汇编代码困难,因此可以考虑在关键代
码部位使用 Native 代码,如注册验证,加解密操作等。一个可能的借助 Native 代码保护 APK
的方法是:将核心业务逻辑代码放入加密的.jar 或者.apk 文件中,在需要调用时使用 Native
代码进行解密,同时完成对解密后文件的完整性校验,不过不管是.jar 还是.apk 文件,解密
后都会留在物理存储上,为了避免这种情况,可以使用反射技术直接调用
dalvik.system.DexFile.openDex()方法,该方法接受 classes.dex 文件字节流返回 DexFile 对象。
关于 Native 代码的编写,可以参考 Google 官方文档的 Android NDK

逆向工具对抗

在逆向分析 Android 应用程序时,一般会使用 apktool,baksmali/smali,dex2jar,androguard,
jdGUI 以及 IDA Pro 等。因此可以考虑使得这些工具在反编译 APK 时出错来保护 APK,这些工
具大部分都是开源的,可以通过阅读其源代码,分析其在解析 APK、dex 等文件存在的缺陷,
在开发 Android 应用程序时加以利用。可以参考 Tim Strazzere 的 Dex Education:Practicing Safe
Dex
,相应的 Demo,看雪上的中文翻译,不过其中的很多技巧已经失效了。DexLabs 的 Dalvik
Bytecode Obfuscation on Android
介绍了垃圾字节码插入的技术。

使用 apktool 进行重打包时,对于后缀为 png 的文件,会按照 png 格式的文件进行打包
处理,因此如果在项目开发时,有意将一个非 png 格式文件的文件名改为后缀为 png 的文件,
则使用 apktool 进行重打包时会出错。可以利用这种方法来对抗重打包。可以试试对这个文
使用 apktool 进行重打包,会报很多错误,但是这种 appt 导致的错误,很多都是由于第一
个错误一起的,如图 23 所示。从第一个错误描述中可知,res/drawable-hdpi/station.png 不是

图 23 apktool 重打包错误

一个 PNG 格式的文件,使用 file 命令,可以发现实际上是一个 Windows icon 文件,如图 24
所示。将这个文件后缀修改成.icon 就可以重新打包了。

图 24 station.png 的真实文件类型

总结

以上 APK 自我保护的技术并不能做到完全的保护作用,只是提高了逆向分析的难度,
在实际运用中应该根据情况多种技术结合使用。这些技术其实很多来源于 Android 恶意代码,
所以可以关注 Android 恶意代码中使用的一些技术来应用到自己开发的 Android 应用程序中。

注:本帖由看雪论坛志愿者PEstone 重新将pdf整理排版,若和原文有出入,以原作者附件为准

总结的一些关于APK自我保护的方法,当然还有很多其他的技巧,无法一一列举,现在可以使用的一些服务包括:
1.梆梆 :http://www.bangcle.com/
2.爱加密 :https://www.ijiami.cn/
3.APKProtect :http://www.apkprotect.com/
4.Shield4J :http://shield4j.com/
5.DexGuard :http://www.saikoa.com/dexguard

欢迎补充!

[培训]二进制漏洞攻防(第3期);满10人开班;模糊测试与工具使用二次开发;网络协议漏洞挖掘;Linux内核漏洞挖掘与利用;AOSP漏洞挖掘与利用;代码审计。

上传的附件:
收藏
点赞1
打赏
分享
最新回复 (81)
雪    币: 31
活跃值: (145)
能力值: ( LV7,RANK:100 )
在线值:
发帖
回帖
粉丝
wdynasty 1 2013-12-28 21:58
2
0
great!
雪    币: 5920
活跃值: (3840)
能力值: ( LV7,RANK:110 )
在线值:
发帖
回帖
粉丝
pxhb 2 2013-12-28 22:11
3
0
收藏备用
雪    币: 168
活跃值: (70)
能力值: ( LV4,RANK:50 )
在线值:
发帖
回帖
粉丝
lixinwk 1 2013-12-28 22:21
4
0
支持一个
雪    币: 7
活跃值: (10)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
dgsj 2013-12-28 23:24
5
0
其实还有个方向,就是防注入,比如卸载被注入的so,ptrace自己等等
雪    币: 208
活跃值: (40)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
justlovemm 2013-12-29 12:22
6
0
支持加收藏加下载!
多谢楼主分享!
雪    币: 1411
活跃值: (401)
能力值: (RANK:270 )
在线值:
发帖
回帖
粉丝
Claud 6 2013-12-29 14:38
7
0
最近给MindMac加精华加得手都酸了……
雪    币: 287
活跃值: (225)
能力值: (RANK:250 )
在线值:
发帖
回帖
粉丝
MindMac 5 2013-12-29 18:10
8
0
你确定不是因为其他事情手酸?
你是版主,我在积极支持你的工作:P
雪    币: 2321
活跃值: (4028)
能力值: ( LV12,RANK:530 )
在线值:
发帖
回帖
粉丝
熊猫正正 9 2013-12-29 18:15
9
0
是哪只手酸,这个要搞清楚,嘿嘿!!学习,学习,多谢分享!
雪    币: 1411
活跃值: (401)
能力值: (RANK:270 )
在线值:
发帖
回帖
粉丝
Claud 6 2013-12-29 19:20
10
0
我错了,您加油……
雪    币: 427
活跃值: (59)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
pwelyn 2013-12-29 20:14
11
0
收藏备用,版主右手都酸了
雪    币: 603
活跃值: (2137)
能力值: ( LV11,RANK:190 )
在线值:
发帖
回帖
粉丝
wuaiwu 3 2013-12-29 20:29
12
0
mark
雪    币: 219
活跃值: (738)
能力值: (RANK:290 )
在线值:
发帖
回帖
粉丝
viphack 4 2013-12-30 01:27
13
0
好东西great
雪    币: 210
活跃值: (20)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
热火朝天 2013-12-30 08:12
14
0
收藏稍后慢慢看
雪    币: 108
活跃值: (44)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
tuobaofeng 2013-12-30 09:03
15
0
very nice
雪    币: 100
活跃值: (10)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
crackhell 2013-12-30 09:16
16
0
收藏以备日后使用
雪    币: 190
活跃值: (40)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
kxzjchen 2013-12-30 09:42
17
0
nice
雪    币: 5
活跃值: (354)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
king少 2013-12-30 12:28
18
0
好东西  很全面   但是好多方法虽然猥琐   却完全抛弃了效率问题。。。。。。。反射的消耗还是很大的。。。。所有东西都用上的话,我很怀疑apk是否能跑起来。。。。
雪    币: 287
活跃值: (225)
能力值: (RANK:250 )
在线值:
发帖
回帖
粉丝
MindMac 5 2013-12-30 14:15
19
0
恩,是的,保护的话也是主要针对部分关键代码,肯定不能全都用上,全都用上开发人员估计要疯了~~
雪    币: 6372
活跃值: (692)
能力值: ( LV5,RANK:70 )
在线值:
发帖
回帖
粉丝
outdoxl 2013-12-31 13:04
20
0
学习了  mark
雪    币: 19
活跃值: (25)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
zypeh 2014-1-1 08:36
21
0
学习了
雪    币: 132
活跃值: (19)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
luyikk 2014-1-1 12:40
22
0
道高一尺 魔高一丈 而已
雪    币: 16
活跃值: (10)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
brucezl 2014-1-2 09:33
23
0
好东西,留位
雪    币: 216
活跃值: (25)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
potop 2014-1-3 14:47
24
0
如果每个方法都有配套的实现代码就更好了!
雪    币: 171
活跃值: (756)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
victorkong 2014-1-3 17:29
25
0
好帖,值得慢慢揣摩,先收藏了 :)
游客
登录 | 注册 方可回帖
返回