Android漏洞扫描工具Code Arbiter

目前Android应用代码漏洞扫描工具种类繁多,效果良莠不齐,这些工具有一个共同的特点,都是在应用打包完成后对应用进行解包扫描。这种扫描有非常明显的缺点,扫描周期较长,不能向开发者实时反馈代码中存在的安全问题,并且对于问题代码的定位需要手动搜索匹配源码,这样就更不利于开发者对问题代码进行及时的修改。Code Arbiter正是为解决上述两个问题而开发的,专门对Android Studio中的源码进行安全扫描。

背景介绍

为实现对Android Studio中的源码进行扫描,最方便的方式便是将扫描工具以IDE插件的形式进行工作。此时一个很自然的想法便是从头构建一个Android Studio插件,但是进行仔细的评估后会发现,这样做难度并不小:

  1. 工作量大,许多知识需要学习,如IDE开放API接口、插件UI构建等,同时许多底层模块需要从头构建;
  2. 插件的稳定性、检测问题的准确性上都不一定能够达到已有开源工具的效果。

因此我们转而考虑在已有漏洞检测插件的基础上进行扩展,以满足需求。经过调研,最终入围的两款检测插件是PMD和FindBugs,其中PMD是对Java源码进行扫描,而FindBugs则是对Java源码编译后的class文件进行扫描。考虑到可扩展性及检测的准确性,最终选定了FindBugs。FindBugs是一个静态分析工具,它检查类或者JAR文件,将字节码与一组缺陷模式进行对比来发现可能的问题,可以以独立的JAR包形式运行,也可以作为集成开发工具的插件形式存在。

扩展优化

那么,怎么扩展FindBugs呢?调研发现FindBugs插件具有着极强的可扩展性,只需要将扩展的JAR包导入FindBugs插件,重启,即可完成相关功能的扩展。安装JAR包示意图如下所示。

下面的问题是如何构建可安装的JAR包。继续调研,发现FindBugs有一款专门对安全问题进行检测的扩展插件Find Security Bugs,该插件主要用于对Web安全问题进行检测,也有极少对Android相关安全问题的检测规则。考虑以下几个原因,需要对该插件的源码进行重构。

  1. 对Android安全问题的检测太少,只包含外部文件使用、Webview、Broadcast使用等寥寥几项;
  2. 检测的细粒度上考虑不够完全,会造成大量的误报,无法满足检测精度的要求;
  3. 检测问题的上报只支持英文模式,且问题展示的逻辑性不够严谨,不便于开发者进行问题排查。

基于以上三个原因,我们需要对Find Security Bugs的源码进行重写、优化,通过增加检测项来检测尽可能多的安全问题,通过优化检测规则来减少检测的误报,问题展示使用中文进行描述,同时优化问题描述的逻辑性,使得开发者能够更易理解并修改相关问题,至此插件实现及优化的方案确定。

工具实现介绍

FindBugs检测的是class文件,因此当待检测的源码未生成编译文件时,FindBugs会先将源码编译生成.class文件,然后对这个class文件进行分析。FindBugs会完成对class文件的自动建模,在此模型的基础上对代码进行分析。按照在实际编写检测代码过程中的总结,把检测的实现方式分成四种方式,下面分别进行介绍。

逐行检查

逐行检查主要是针对代码中使用的一些不安全方法或参数进行检测,其实现方式是重写sawOpcode()方法,下面以Android中使用外部存储问题作为示例进行讲解。

Android中获取外部存储文件夹地址的方法主要包括下面这些方法:

getExternalCacheDir()
getExternalCacheDirs()
getExternalFilesDir()
getExternalFilesDirs()
getExternalMediaDirs()
Environment.getExternalStorageDirectory()
Environment.getExternalStoragePublicDirectory()

检测的方式便是,如果发现存在该方法的调用,则作为一个问题进行上报,实现完整代码如下所示:

public class ExternalFileAccessDetector extends OpcodeStackDetector {    private static final String ANDROID_EXTERNAL_FILE_ACCESS_TYPE = "ANDROID_EXTERNAL_FILE_ACCESS";    private BugReporter bugReporter;    public ExternalFileAccessDetector(BugReporter bugReporter) {        this.bugReporter = bugReporter;
    }    @Override
 public void sawOpcode(int seen) {        //printOpCode(seen);
 if (seen == Constants.INVOKEVIRTUAL && (
        getNameConstantOperand().equals("getExternalCacheDir") ||
        getNameConstantOperand().equals("getExternalCacheDirs") ||
        getNameConstantOperand().equals("getExternalFilesDir") ||
        getNameConstantOperand().equals("getExternalFilesDirs") ||
        getNameConstantOperand().equals("getExternalMediaDirs")
            )) {// System.out.println(getSigConstantOperand());
 bugReporter.reportBug(new BugInstance(this, ANDROID_EXTERNAL_FILE_ACCESS_TYPE, Priorities.NORMAL_PRIORITY).addClass(this).addMethod(this).addSourceLine(this));
        }        else if(seen == Constants.INVOKESTATIC && getClassConstantOperand().equals("android/os/Environment") && (getNameConstantOperand().equals("getExternalStorageDirectory") || getNameConstantOperand().equals("getExternalStoragePublicDirectory"))) {
            bugReporter.reportBug(new BugInstance(this, ANDROID_EXTERNAL_FILE_ACCESS_TYPE, Priorities.NORMAL_PRIORITY).addClass(this).addMethod(this).addSourceLine(this));
        }
    }
}

该类的实现是继承OpcodeStackDetector类,是FindBugs中的一个抽象类,封装了对于获取代码特定参数的方法调用。sawOpcode方法参数可以理解为待检测代码行的行号,通过printOpCode(seen)可以打印该代码行的具体内容。

Constants.INVOKEVIRTUAL表示该行调用类的实例方法,Constants.INVOKESTATIC表示调用类的静态方法。getNameConstantOperand方法表示获取被调用方法的名称,getClassConstantOperand方法表示获取调用类的名称,getSigConstantOperand方法表示获取方法的所有参数。

bugReporter.reportBug用于上报检测到的漏洞信息,其中BugInstance的三个参数分别表示:检测器、漏洞类型、漏洞等级,其中漏洞等级分为五个级别,如下表所示:

addClass、addMethod、addSourceLine用于指定该漏洞所在的类、方法、行,方便报告漏洞时定位关键代码。

逐方法检查

逐方法检查首先获取待检测类的所有内容,然后对类中的方法进行逐个检查,多用于对方法体进行检测,其实现的方法主要是通过重写visitClassContext方法,下面以对Android TrustManager的空实现的检测为例进行说明。

TrustManager的空实现,主要是指对于检测Server端证书是否可信的方法checkServerTrusted,是否是空实现。下面展示问题代码,如果是空实现那么将导致客户端接收任意证书,从而造成加密后的HTTPS消息被中间人解密。

@Overridepublic void checkServerTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {

}

检测的方式是通过遍历类中的所有方法,找到checkServerTrusted方法,对方法整体进行检测,确定其是否为空实现,部分代码如下所示:

public class WeakTrustManagerDetector implements Detector {
...public WeakTrustManagerDetector(BugReporter bugReporter) {        this.bugReporter = bugReporter;
    }    @Override
 public void visitClassContext(ClassContext classContext) {
        JavaClass javaClass = classContext.getJavaClass();        //The class extends X509TrustManager
  boolean isTrustManager = InterfaceUtils.isSubtype(javaClass,"javax.net.ssl.X509TrustManager");        boolean isHostnameVerifier = InterfaceUtils.isSubtype(javaClass,"javax.net.ssl.HostnameVerifier");// if (!isTrustManager && !isHostnameVerifier) return;
 if (!isTrustManager && !isHostnameVerifier){            for (Method m : javaClass.getMethods()) {
                allow_All_Hostname_Verify(classContext, javaClass, m);
            }
        }

        Method[] methodList = javaClass.getMethods();        for (Method m : methodList) {
            MethodGen methodGen = classContext.getMethodGen(m);            if (DEBUG) System.out.println(">>> Method: " + m.getName());            if (isTrustManager &&
                    (m.getName().equals("checkClientTrusted") ||
                     m.getName().equals("checkServerTrusted") ||
                     m.getName().equals("getAcceptedIssuers"))) {                if(isEmptyImplementation(methodGen)) {
                    bugReporter.reportBug(new BugInstance(this, WEAK_TRUST_MANAGER_TYPE, Priorities.NORMAL_PRIORITY).addClassAndMethod(javaClass, m));
                }
......

classContext.getJavaClass用于获取整个类的所有内容;javaClass.getMethods用于获取该类中的所有方法,以一个方法列表的形式返回;classContext.getMethodGen用于获取该方法的内容;isEmptyImplementation将方法的内容导入该函数进行检测,用于确定方法是否是空实现,该方法的代码如下所示:

private boolean isEmptyImplementation(MethodGen methodGen){    boolean invokeInst = false;    boolean loadField = false;    for (Iterator itIns = methodGen.getInstructionList().iterator();itIns.hasNext();) {
        Instruction inst = ((InstructionHandle) itIns.next()).getInstruction();        if (DEBUG)
            System.out.println(inst.toString(true));        if (inst instanceof InvokeInstruction) {
            invokeInst = true;
        }        if (inst instanceof GETFIELD) {
            loadField = true;
        }
    }    return !invokeInst && !loadField;
}

该方法主要用于检测方法中是否包含方法调用、域操作,如果没有包含则认为是一个空实现的方法。因此该方法对于只包含 return true/false 语句的方法体同样认为是一个空实现。

污点分析

数据流分析主要用于分析特定方法加载的参数是否能够被用户控制,即进行污点分析。做污点分析首先需要定义污染源(source点),污染源可以理解为能够被用户控制的输入数据,这里定义的Android污染源主要包括用户的输入、Intent传入的数据,下面展示定义的部分污染源(source点):

- EditText
android/widget/EditText.getText()Landroid/text/Editable;:TAINTED
- Intent
android/content/Intent.getAction()Ljava/lang/String;:TAINTED
android/content/Intent.getStringExtra(Ljava/lang/String;)Ljava/lang/String;:TAINTED
......
- Bundle
android/os/Bundle.get(Ljava/lang/String;)Ljava/lang/Object;:TAINTED
android/os/Bundle.getString(Ljava/lang/String;)Ljava/lang/String;:TAINTED
......

定义好污染源后就需要确定污染的触发点(sink点),可以理解为会触发危险操作的函数。定义sink点的方式有两种,一种是直接从文件中导入,以命令注入为示例,代码如下:

public class CommandInjectionDetector extends BasicInjectionDetector {    public CommandInjectionDetector(BugReporter bugReporter) {        super(bugReporter);
        loadConfiguredSinks("command.txt", "COMMAND_INJECTION");
 }

从代码中可以清楚的看到其导入方式是继承BasicInjectionDetector类,然后再该类的构造方法中通过loadConfiguredSinks方法,导入包含sink点的文件,下面展示该示例文件中的内容:

java/lang/Runtime.exec(Ljava/lang/String;)Ljava/lang/Process;:0
java/lang/Runtime.exec([Ljava/lang/String;)Ljava/lang/Process;:0
java/lang/Runtime.exec(Ljava/lang/String;[Ljava/lang/String;)Ljava/lang/Process;:0,1
java/lang/Runtime.exec([Ljava/lang/String;[Ljava/lang/String;)Ljava/lang/Process;:0,1
java/lang/Runtime.exec(Ljava/lang/String;[Ljava/lang/String;Ljava/io/File;)Ljava/lang/Process;:1,2
java/lang/Runtime.exec([Ljava/lang/String;[Ljava/lang/String;Ljava/io/File;)Ljava/lang/Process;:1,2
java/lang/ProcessBuilder.<init>([Ljava/lang/String;)V:0
java/lang/ProcessBuilder.<init>(Ljava/util/List;)V:0
java/lang/ProcessBuilder.command([Ljava/lang/String;)Ljava/lang/ProcessBuilder;:0
java/lang/ProcessBuilder.command(Ljava/util/List;)Ljava/lang/ProcessBuilder;:0
dalvik/system/DexClassLoader.loadClass(Ljava/lang/String;)Ljava/lang/Class;:0

另一种是自定义导入,其实现是通过覆盖BasicInjectionDetector类中的getInjectionPoint方法,以WebView.loadurl方法为例,示例代码如下所示:

@Override
 protected InjectionPoint getInjectionPoint(InvokeInstruction invoke, ConstantPoolGen cpg, InstructionHandle handle) {        assert invoke != null && cpg != null;
        String method = invoke.getMethodName(cpg);
        String sig    = invoke.getSignature(cpg);// System.out.println(invoke.getClassName(cpg));
 if(sig.contains("Ljava/lang/String;")) {            if("loadUrl".equals(method)){                if(sig.contains("Ljava/util/Map;")){                    return new InjectionPoint(new int[]{1}, WEBVIEW_LOAD_DATA_URL_TYPE);
                }else{                    return new InjectionPoint(new int[]{0}, WEBVIEW_LOAD_DATA_URL_TYPE);
                }
            }else if("loadData".equals(method)){                return new InjectionPoint(new int[]{2}, WEBVIEW_LOAD_DATA_URL_TYPE);
            }else if("loadDataWithBaseURL".equals(method)){                //BUG
 return new InjectionPoint(new int[]{4}, WEBVIEW_LOAD_DATA_URL_TYPE);
            }
        }        return InjectionPoint.NONE;
    }

通过实例化InjectionPoint类构造新的sink点,其构造方法中的第一个参数表示该方法接收污染数据参数的位置,如方法为webView.loadUrl(url),其第一个参数就是new int[]{0},其它的以此类推。

上报发现漏洞的情况,则通过覆盖getPriorityFromTaintFrame方法的实现,示例代码如下所示:

@Override
 protected int getPriorityFromTaintFrame(TaintFrame fact, int offset)
            throws DataflowAnalysisException {
        Taint stringValue = fact.getStackValue(offset);// System.out.println(stringValue.getConstantValue());
 if (stringValue.isTainted() || stringValue.isUnknown()) {            return Priorities.NORMAL_PRIORITY;
        } else {            return Priorities.IGNORE_PRIORITY;
        }
    }

通过fact.getStackValue获取检测的函数变量,如果该变量被污染(isTainted)或 变量是否被污染未知(但是是可控制变量),那么作为一个中危风险(Priorities.NORMAL_PRIORITY)进行上报,其它的情况则上报为可忽略风险(Priorities.IGNORE_PRIORITY)。

自定义代码检测

自定义代码检测实现的前半部分同2.2的逐方法检测类似,均是获取类的内容,然后遍历所有的方法,对方法的内容进行检测,但是在具体代码检测实现上是通过自定义分析进行。

目前自定义检测只应用到Android中本地拒绝服务的检测。本地拒绝服务的被触发的重要原因在于对通过Intent获取的参数未进行异常捕获,因此检测实现的方式便是检测获取参数的代码行是否被try catch包裹(这个存在误差,待改进)。对于其代码分析,不能使用FindBugs模型进行分析,而是使用最原始的class代码进行分析,原始class代码的形式通过javap命令进行查看,下图展示示例代码。

对原始class文件进行分析存在的缺陷是无法定位具体的代码行,那么在进行问题上报时无法将问题定位到代码行,因此第一步需要在原有模型的基础上对所有包含Intent获取参数的方法的位置存储到一个Map结构中,方便后面对方法的定位,代码实现如下所示,获取方法所在的行,然后以方法名作为Key值,以代码行相关信息作为Value值,存储到Map中。

private Map<String, List<Location>> get_line_location(Method m, ClassContext classContext){
        HashMap<String, List<Location>> all_line_location = new HashMap<>();
        ConstantPoolGen cpg = classContext.getConstantPoolGen();
        CFG cfg = null;        try {
            cfg = classContext.getCFG(m);
        } catch (CFGBuilderException e) {
            e.printStackTrace();            return all_line_location;
        }        for (Iterator<Location> i = cfg.locationIterator(); i.hasNext(); ) {
            Location loc = i.next();
            Instruction inst = loc.getHandle().getInstruction();            if(inst instanceof INVOKEVIRTUAL) {
                INVOKEVIRTUAL invoke = (INVOKEVIRTUAL) inst; if(all_line_location.containsKey(invoke.getMethodName(cpg))){
                        all_line_location.get(invoke.getMethodName(cpg)).add(loc);
                    }else {
                        LinkedList<Location> loc_list = new LinkedList<>();
                        loc_list.add(loc);
                        all_line_location.put(invoke.getMethodName(cpg), loc_list);
                    }// }
 }
        }        return all_line_location;
    }

之后获取Exception包裹的范围,FindBugs中包含对Exception的建模,因此能够通过其模型能够直接获取其范围并存储到一个列表中,代码如下所示,其中exceptionTable[i].getStartPC用于获取try catch 的起始代码行,exceptionTable[i].getEndPC用于获取try catch 的结束代码行。

public int[] getExceptionScope(){        try {
            CodeException[] exceptionTable = this.code.getExceptionTable();            int[] exception_scop = new int[exceptionTable.length * 2];            for (int i = 0; i < exceptionTable.length; i++) {
                exception_scop[i * 2] = exceptionTable[i].getStartPC();
                exception_scop[i * 2 + 1] = exceptionTable[i].getEndPC();
            }            return exception_scop;
        }catch (Exception e){
 }        return new int[0];
    }

在对代码进行逐行检查时,因为使用的是最原始class文件形式,因此需要限定其遍历的范围,限定的方式是通过代码的行号,即上图中每行代码的第一个数值。首先需要获取代码总行数的大小,获取的方式便是解析FindBugs建模后的第一行代码,找到关键词code-length后面的数值,即为代码的行数,解析代码如下所示:

public int get_Code_Length(String firstLineCode){        try{
            String[] split1 = firstLineCode.split("code_length");// System.out.println(split1[split1.length-1]);
 byte[] code_length_bytes = split1[split1.length-1].getBytes();            byte[] new_code_bytes = new byte[code_length_bytes.length];            for(int i=0; i<code_length_bytes.length; i++){// System.out.println();
 if(code_length_bytes[i]<48 || code_length_bytes[i]>57){
                    new_code_bytes[i] = 32;
                }else{
                    new_code_bytes[i] = code_length_bytes[i];
                }
            }            return Integer.parseInt(new String(new_code_bytes).trim());
        }catch(Exception e){
            e.printStackTrace();
        }        return 0;
    }

最后对代码进行逐行遍历,遍历中为防止try catch块被遍历到,使用行号来限制遍历的范围。检测代码行是否包含通过Intent获取参数,及该行是否被try catch 包裹,如果上述两个条件均被触发,那么就作为一个问题进行上报。示例代码如下,其中get_code_line_index方法用于获取代码的行号,获取的方式是截取代码行的首字符的数值,以确定是否在代码包裹的范围内。

private void analyzeMethod(JavaClass javaClass, Method m, ClassContext classContext) throws CFGBuilderException {
        HashMap<String, List<Location>> all_line_location = (HashMap<String, List<Location>>) get_line_location(m, classContext);
        Code code = m.getCode();
        StringCodeAnalysis sca = new StringCodeAnalysis(code);
        String[] codes = sca.codes_String_Array();        int code_length = sca.get_Code_Length(sca.get_First_Code(codes));        int[] exception_scop = sca.getExceptionScope();        for(int i=1; i<codes.length; i++){            int line_index = sca.get_code_line_index(codes[i]);            if (line_index < code_length){                if(codes[i].toLowerCase().contains("invokevirtual") &&
                        (codes[i].contains("android.content.Intent.get")  || codes[i].contains("android.os.Bundle.get"))){                    if(exception_scop.length == 0){
                        ......
                    }else{                        boolean is_scope = false;                        for(int j=0; j<exception_scop.length; j+=2){                            int start = exception_scop[j];                            int end = exception_scop[j+1];                            if(line_index >= start && line_index <= end){
                                is_scope = true;
                            }                            if(is_scope){                                break;
                            }
                        }                        if(!is_scope){
                            String method_name = get_method_name(codes[i]);                            if(all_line_location.containsKey(method_name)){                                for(Location loc : all_line_location.get(method_name)){
                                    bugReporter.reportBug(new BugInstance(this, LOCAL_DENIAL_SERVICE_TYPE, Priorities.NORMAL_PRIORITY).addClass(javaClass).addMethod(javaClass, m).addSourceLine(classContext, m, loc));
                                }
                            }else {
                                bugReporter.reportBug(new BugInstance(this, LOCAL_DENIAL_SERVICE_TYPE, Priorities.NORMAL_PRIORITY).addClass(javaClass).addMethod(javaClass, m));
 }
                        }
                    }
                }
            }
        }
    }

注册打包

上面详细叙述了如何构造自己的问题检测代码,完成检测方法的书写后,下一步就是在配置文件中对检测方法进行注册,才能使检测代码运转起来。

需要在两个文件中进行注册,第一个是findbugs.xml,注册示例如下:

<Detector class="com.h3xstream.findsecbugs.android.LocalDenialOfServiceDetector" reports="LOCAL_DENIAL_SERVICE"/>
<BugPattern type="LOCAL_DENIAL_SERVICE" abbrev="SECLDOS" category="Android安全问题" cweid="276"/>

其中Detector用于注册该检测方法的位置及其唯一标识,BugPattern用于对检测出的问题进行归类,方便展示,如此处归类到"Android安全问题"中,那么在生成报告的时候问题也将被归类到"Android安全问题"中。

第二个是messages.xml注册,注册示例如下,该注册主要是对该问题进行说明,包括问题的危害及修复方法。

<Detector class="com.h3xstream.findsecbugs.android.LocalDenialOfServiceDetector">
<Details>Local Denial of Service.</Details>
</Detector>
<BugPattern type="LOCAL_DENIAL_SERVICE">
<ShortDescription>本地拒绝服务</ShortDescription>
<LongDescription>通过Intent接收的参数未进行异常捕获,导致出现异常使得应用崩溃</LongDescription>
<Details>
<![CDATA[
    <p>
        <b>危害:</b><br/>
        <pre>
            应用崩溃无法使用,影响用户体验;
            被竞争对手利用,进行点对点攻击。
        </pre>
    </p>
    <p>
        <b>错误代码:</b><br/>
        <pre>
            bundle.getString(""); //未try/catch
            intent.getStringExtra(""); //未try/catch
        </pre>
    </p>
    <p>
        <b>解决方案:</b><br/>
        <pre>
            对通过Intent接收的参数处理时,进行严格的异常捕获。
            try {
                bundle.getString("");
                intent.getStringExtra(""); 
            }catch (Exception e){}
        </pre>
    </p>
]]>
</Details>
</BugPattern>
<BugCode abbrev="SECLDOS">本地拒绝服务</BugCode>

一切完成就绪后使用Maven进行打包,就生产了供FindBugs集成开发工具插件使用的JAR包,完成安装并重启,即可使用自定义插件对特定问题进行检测。

最终分析的效果图如下图所示:

结语

本文介绍了Android集成开发环境Android Studio的代码实时检测工具Code Arbiter的产生原因及代码实现,最后展示了分析的效果。通过Code Arbiter在生产环境中的应用,其检测效果还是相当不错,能够发现很多编码过程中存在的问题。但是Code Arbiter仍然存在许多不足,需要优化。后续将在以下两个方面对工具进行改进:

  1. 扩大漏洞检测范围,使Code Arbiter能够囊括Android编码常见安全问题;
  2. 优化漏洞检测规则,提高检测的准确性,减少误报。

原文发布于微信公众号 - 美团点评技术团队(meituantech)

原文发表时间:2017-08-17

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏后端之路

被低估的前端模块化

对于前端的缓存主要包括静态资源的缓存 为避免出现类似问题参考 变态的静态资源缓存与更新 在经历了上传前端资源文件等之后测试发现了一堆问题 发现每一个出现问题的均...

23710
来自专栏LEo的网络日志

python技巧分享(十三)

1383
来自专栏逆向技术

异常处理第二讲,结构化异常(微软未公开)

            异常处理第二讲,结构化异常(微软未公开) 讲解之前,请熟悉WinDbg的使用,工具使用的博客链接 一丶认识段寄存器FS的内容,以及作用 ...

1867
来自专栏智能大石头

新生命开发团队Orm框架XCode v3.5.2009.0714源码发布(圣诞随心大礼包)

忙忙碌碌有一年!做了很多东西,到头来,似乎又什么都没有做。人继续变老,程序继续改进。     这段时间从我们各个系统抽取了基础的常用的部分,整理后形成了一个...

1807
来自专栏大内老A

一个通过JSONP跨域调用WCF REST服务的例子(以jQuery为例)

JSONP(JSON with Padding)可以看成是JSON的一种“使用模式”,用以解决“跨域访问”的问题,这篇简单的文章给出一个简单的例子用于模拟如何通...

1897
来自专栏有趣的Python和你

千里之行,始于足下变量字符串

823
来自专栏AhDung

【C#】分享带等待窗体的任务执行器一枚

-------------20150415原文(已更新)-------------

823
来自专栏郭耀华‘s Blog

Java英文单词Java基础常见英语词汇

Java英文单词Java基础常见英语词汇(共70个) OO: object-oriented ,面向对象               OOP:object-...

2777
来自专栏hightopo

原 荐 快速开发 HTML5 WebGL 的

2823
来自专栏移动端开发

这个断点可以帮你检查布局约束

前言:     在现在iOS布局中,估计有很多很多开发者会使用到 Masonry 或者用到 SDAutoLayout 或者Storyboard或者还有Xib等等...

2039

扫码关注云+社区