网易乐得技术团队

代码在线编译器(下)- 用户代码安全检测

前文连接

案例的介绍已在前文中给出,本文中对相关部分将不再叙述。为更好地阅读本文,需要简单了解背景,建议可以大致浏览下前文:

代码在线编译器(上)- 编辑及编译

安全检测

在线编译器中的安全检测,目的是确定用户代码是否能够安全的运行,且不对运行环境产生危害。仍以一般场景和特殊场景(前文有说明)举例区分:

  • 一般场景:用户代码仅依赖原生库,运行环境选择沙箱情况下,沙箱间相互独立,用户代码导致的环境损害只会作用于单一沙箱,不会影响到其他沙箱及底层系统的正常使用。
  • 特殊场景:用户代码依赖平台提供API,运行环境无法使用独立的沙箱,用户代码不良操作可能会引起整个运行环境的异常,从而导致其他用户代码运行失败或服务器崩溃等情况。

所以,在特殊场景下,如何对用户代码是否符合安全要求作出判定,是安全 检测的内容。

在搭建一个合理的安全检测流程时,不能直接进入对代码分析的阶段,在此之前搞清楚用户代码生命周期涉及的各个阶段是必要的,只有充分了解被检测对象和检测目标的基础上,才能给出一个合理的流程:

  • 谁写的代码:代码编写的用户及用户形象
  • 代码写成什么样:代码语言、结构、内容等内容分析
  • 代码怎么用:代码运行、功能、效果等调用分析
  • 如何检测:设计合理检测流程

用户形象 -“谁写的代码”

用户形象及背景的分析,往往是在纯技术实现过程中容易忽略的问题。

在一开始搭建网易贵金属量化平台的时候,大部分精力放置在基于用户代码本身的安全检测方案上,忽略了对用户角度的考虑,导致在没有对用户的形象及能力有一个正确评估的情形下构建的安全检测方案,总出现“出乎意料”、“覆盖不全”等情况,用户代码总超出预想范围。反思后,我们更换了切入点,先从用户形象和可能的行为入手,重新构思并设计了检测流程,使得检测的覆盖方面大大增加,实现起来也更为容易。

用户形象,主要是明确代码编写的用户主体,究竟是以何种状态和知识水平参与到代码编写过程中的,需要明确的用户形象相关的内容主要可以涵盖:

  • 用户编程知识背景:用户是否具备相关编程语言的编程经验,以及编程语言的熟悉程度。最不利的假设,如果用户甚至没接触过对应的编程语言,直接在代码demo上做修改,一定会出现各种各样的错误。
  • 用户业务知识背景:用户是否具备代码涉及业务的知识背景,以及业务知识的掌握程度。特殊场景下,用户代码编写一业务背景相对较明确的代码时,如果对业务知识背景掌握不是很好,写出来的代码可能未必符合合理的逻辑。
  • 用户操作引导效果:用户是否会按照平台给出的引导进行操作。如果用户对帮助文档等引导未做到详细阅读以及完全理解,用户代码在结构和逻辑上可能都会出问题。
  • 用户意图:用户使用平台的意图是不明确的,有一定可能会存在恶意用户以破坏平台为目的,利用在线编译器进行破坏。

对上述关注点,不能对用户的情况抱有幻想,所做的用户形象假设应考虑最不利的情况。简而言之:

用户代码永远是不可信的。

案例说明

网易贵金属量化平台(上一篇文章中已做介绍),提供Java在线编译器,用户代码内容为量化投资策略逻辑描述,用户形象中构想的一些不利情况下的假设:

  1. 用户背景不尽相同,有些用户之前可能未接触过Java,不能奢求代码语法合法性
  2. 用户技术水平是参差不齐的,代码写成什么样都有可能,不能奢求代码规范
  3. 即便是帮助文档写的再好,用户也未必会看的完全,不能奢求代码操作合理性
  4. 用户未必会按照你的要求规范,老老实实按照给出的Demo流程书写,不能奢求代码符合预定流程
  5. 用户未必清楚一个量化策略从何开始、到哪结束、需要包含什么内容,不能奢求代码逻辑合理性
  6. 可能出现一些恶意用户,目的就是来搞破坏的,不能奢求代码安全性

首先把用户代码可能触发安全问题的原因明确后,再根据不同的原因设计对应的解决方案,就会大大提升安全检测流程的有效性。

代码内容分析 -“代码写成什么样”

代码内容分析,主要是分析代码涉及的内容、结构等方面的情况,以方便在未获得用户代码之前,对用户代码可能涉及和存在的内容作出预判,从而构思应对思路,关注点涵盖:

  • 代码结构:以Java为例,可以明确是否以类为主体,是否包含必要的方法,是否有明确的方法结构
  • 业务逻辑:代码本身是否存在明确的业务逻辑结构
  • 包含引用:代码是否必须引用其他类,或者类中的某个方法
  • 数据交互:用户代码和平台间存在哪些涉及到数据交互的内容,输入输出的内容。

以上的关注点明确后,配合平台在用户代码构建阶段对代码结构的固定,基本可以在用户代码获得前,明确用户代码的大致过程。

案例说明

网易贵金属量化平台,代码结构固定,必须实现策略类模板接口(上一篇文章有介绍),业务逻辑上非常明确,策略代码的行为抽象主要包含:

image

通过对策略代码的行为抽象,即可明确代码所包含的所有业务、业务对应类引用以及会产生数据交互的内容。上图中将行为抽象为树结构,对行为点以范畴进行区分,并明确标记输入(I)、输出(O)等产生数据交互的关注点,以便之后根据此构建代码检测流程。

代码使用分析 -“代码怎么用”

用户代码最终需要加载至运行环境进行调用的,在平台调用用户代码的过程中,调用细节也会影响到安全检测的规划及构思细节。关于使用分析的关注点,可以从以下方面考虑:

  • 编译过程:编译过程细节
    • 语言是否需要编译
    • 编译是否伴随诊断,而诊断过程中包含的诊断内容
    • 编译结果存储形式(文件、数据库或内存)
  • 调用过程:平台调用细节
    • 调用用户代码中的何类
    • 调用用户代码中的何方法
    • 调用方式(一次性调用、循环触发、定时触发、事件触发等)
    • 调用期间数据交互(内容分析基础上,分析数据最初来源及最终流向,文件、数据库、内存等)
  • 运行结束:结束用户代码留存情况
    • 调用后是否会被平台用作其他功用
    • 调用后代码编译类是否会被类加载器剔除
    • 调用后用户代码是否还会留存在系统内

通过使用分析,可以规划出在整个生命周期内,用户代码跟平台产生交互的部分,这些不同层次的交互中可能会对系统安全产生威胁,故在安全检测流程设计时,应结合不同的交互层次给出不同检测策略。

案例说明

网易贵金属量化平台(前文对案例有所介绍),在使用分析的流程上大概可简述为:

  1. 编译过程:Java语言,需要进行编译,编译过程伴随诊断,最终编译结果以类字节码的形式存在,无需落地
  2. 调用过程:
    1. 用户代码为策略类的实现,调用时,该类被加载至服务器的类加载器。
    2. 调用策略类中的init方法以完成该策略类对象初始化、循环调用handle方法以在行情的每一个调度周期内完成相应动作、最终调用onExit方法来完成策略调用过程。
    3. 数据交互上,用户策略类在使用过程中,会读取内存中的行情信息,并根据策略逻辑内容产生交易信息(开仓信息、平仓信息)。
  3. 运行结束:用户代码在调用后,用户策略类将不会做其他使用,类加载器中将把该类从加载器中剔除,策略类的实例对象将会被GC回收。

安全检测流程 -“如何检测”

上述流程后,用户代码的生命周期及使用场景基本描绘完全,可根据具体场景构建安全检测流程。

布局

不要奢求安全检测能够一次性完成,即便是一次性能够实现安全检测的目的,代价上也是得不偿失的。

安全检测应分布于代码生命周期的各个阶段。

能够一次性检测的方法,理论上一定会部署在整个代码生命周期内足够靠后的位置,一旦用户代码被检测出安全问题不在被平台运行时,之前的所有操作都是白费的;如果采用将安全检测分布于生命周期的各个阶段的方案,不同阶段解决不同的安全检测问题,能够尽早发现代码的安全问题,从而尽早打断,减少不必要的操作以及资源消耗。

检测内容及目的

简而言之,检测的目的就是:保证用户代码仅在允许范围内可用

以此为根本目的,检测内容的关注点可包含:

  • 用户代码是否使用了规定范围外的类
  • 用户代码是否使用了规定范围外的方法
  • 用户代码是否存在了规定范围外的行为
  • 用户代码是否存在了不易检测的不良行为(大内存使用、长时间线程占用)

以上内容相对较为抽象,后文将结合具体案例,对使用到的相关技术及具体细节作出介绍。

案例说明

网易贵金属量化平台,随着对Java在线编译器认知的不断加深,安全检测流程也在不断的健全。

最简单的版本

用户可以自主导入JDK相关包,平台补充API相关包。源代码生成后,利用JavaComplier中的诊断信息,即能完全判定某策略类是否在当前的项目环境中可运行的。

面临的问题

  • 未对import导入的包做限制,用户可使用JDK所有类
  • 即便对import做限制后,默认导入的java.lang以及当前类所在包是默认导入的,其中的类无法限制导入

导致的问题

  • 用户可以使用System等系统相关类,影响系统状态
  • 用户可以向外部发送网络请求
  • 用户可以自己创建线程

更新后的检测流程

通过对用户代码的内容分析、运行分析后,得出这样一个结论:如果只希望用户使用平台提供的服务与行为,就必须限制其余行为,但行为的执行实质就是“方法”,但方法的执行主体是“类及对象”,所以“限制行为的实质就是限制类使用”。

以此认识为核心,并按照将检测流程分布在代码生命周期的各个阶段的思想,量化平台规划出的检测流程如下:

image

检测流程被分布在了编译前、编译时、编译后的各个流程内,具体内容包括:

  • 编译前-编译预检:
    • 代码简单词法分析:
      • 检测文件是否为空
      • 检测文件是否单独进行包导入
      • 检测文件是否包含策略模板接口实现类
      • 检测类是否唯一以及是否存在内部类
    • 代码存放位置限定:
      • 指定所有编译后的类所在包路径
  • 编译时-编译诊断:
    • 获得类源代码编译过程中的诊断信息,判定是否存在诊断
  • 编译后-结果检查:
    • 类列表安全检查
      • 对编译后的.class字节码进行解析,找到其中涉及到的所有类,进行安全类列表的白名单检查

对于编译前及编译时涉及到的检测内容,在前文《代码在线编译器(上)- 编辑及编译》中均有过详细的介绍,这里主要介绍下类列表安全检查的相关内容。

类列表安全检查

目的就是,找到用户策略中所有涉及到的类,与类白名单进行比对,验证这些类是否均在白名单内,如果出现了名单外的类,认为用户代码不安全,可拒绝运行,并给用户作出反馈。

类白名单

类白名单中的类来自两部分:

  • 平台提供的API所涉及的类
  • 必须使用的JDK中的类(java.lang中的类也要单独指明,java.lang中不是所有类都可用)
  • 三方工具包中的类
获得涉及类

在获得用户代码所有涉及类的过程中,思路有两种:

  • 从源代码进行分析:利用编译原理中的抽象语法树(Abstract Syntax Tree)
  • 从字节码进行分析:利用Java字节码框架,直接分析字节码构成。

class类字节码作为代码编译后最终体现,可以反馈实际用到的所有类,实际实现过程中,也是选用此方案进行实施的。

使用的字节码框架为ASM

ASM使用

简介

ASM 是一个 Java 字节码操控框架。它能被用来动态生成类或者增强既有类的功能。ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为。Java class 被存储在严格格式定义的 .class 文件里,这些类文件拥有足够的元数据来解析类中的所有元素:类名称、方法、属性以及 Java 字节码(指令)。ASM 从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据要求生成新类。ASM最常用的使用场景就是静态AOP的实现(例如CGLIB)。

本文使用ASM,分析类信息,从而提炼出类字节码中使用涉及到的所有类。

class文件结构

在分析class文件之前,先对class结构作出简要介绍:

image

文件各部分含义如下:

  • Magic:该项存放了一个 Java 类文件的魔数(magic number)和版本信息。一个 Java 类文件的前 4 个字节被称为它的魔数。每个正确的 Java 类文件都是以 0xCAFEBABE 开头的,这样保证了 Java 虚拟机能很轻松的分辨出 Java 文件和非 Java 文件。
  • Version:该项存放了 Java 类文件的版本信息。
  • Constant Pool:该项存放了类中各种文字字符串、类名、方法名和接口名称、final变量以及对外部类的引用信息等常量。虚拟机必须为每一个被装载的类维护一个常量池,常量池中存储了相应类型所用到的所有类型、字段和方法的符号引用。
  • Access_flag:该项指明了该文件中定义的是类还是接口(一个class文件中只能有一个类或接口),同时还指名了类或接口的访问标志,如 public,private, abstract 等信息。
  • This Class:指向表示该类全限定名称的字符串常量的指针。
  • Super Class:指向表示父类全限定名称的字符串常量的指针。
  • Interfaces:一个指针数组,存放了该类或父类实现的所有接口名称的字符串常量的指针。
  • Fields:该项对类或接口中声明的字段进行了细致的描述,仅列出了本类或接口中的字段,并不包括从超类和父接口继承而来的字段。
  • Methods:该项对类或接口中声明的方法进行了细致的描述。例如方法的名称、参数和返回值类型等。需要注意的是,methods 列表里仅存放了本类或本接口中的方法,并不包括从超类和父接口继承而来的方法。
  • Class attributes:该项存放了在该文件中类或接口所定义的属性的基本信息。

在这些内容中涉及到类名的部分为Fileds和Methods,需使用ASM对这一部分内容进行浏览。

class文件内容获取

以一段简单的包含Fields和Methods的代码为例,说明一下类在字节码中的体现形式。

源码

1
2
3
4
5
6
7
8
9
10
11
12
13
public class IniBean {
private static Logger logger = LoggerFactory.getLogger("inibean");

private static AtomicBoolean initFlag = new AtomicBoolean(true);

@PostConstruct
public void init() {
logger.info("[IniBean] init method invoke.");
if (initFlag.getAndSet(false)) {
refreshIniInfo();
}
}
...

编译后字节码

1
2
3
4
5
6
7
8
9
10
11
12
public void init();
Code:
0: getstatic #2 // Field logger:Lorg/slf4j/Logger;
3: ldc #3 // String [IniBean] init method invoke.
5: invokeinterface #4, 2 // InterfaceMethod org/slf4j/Logger.info:(Ljava/lang/String;)V
10: getstatic #5 // Field initFlag:Ljava/util/concurrent/atomic/AtomicBoolean;
13: iconst_0
14: invokevirtual #6 // Method java/util/concurrent/atomic/AtomicBoolean.getAndSet:(Z)Z
17: ifeq 24
20: aload_0
21: invokevirtual #7 // Method refreshIniInfo:()V
24: return

编译后的字节码中,类名以文件结构(注意此处不是“.”而是以“/”分割的路径,通过class.getClassName()获得的是以“.”分割的类包路径,比对前需要注意转换)进行体现,利用ASM浏览类文件字节码即可获得涉及的类列表。

ASM过程
关键类说明

简要说明几个用到的关键类:

  • org.objectweb.asm.ClassVisitor
    1
    2
    3
    4
    5
    6
    7
    8
    /**
    * A visitor to visit a Java class. The methods of this class must be called in
    * the following order: <tt>visit</tt> [ <tt>visitSource</tt> ] [
    * <tt>visitOuterClass</tt> ] ( <tt>visitAnnotation</tt> |
    * <tt>visitTypeAnnotation</tt> | <tt>visitAttribute</tt> )* (
    * <tt>visitInnerClass</tt> | <tt>visitField</tt> | <tt>visitMethod</tt> )*
    * <tt>visitEnd</tt>.
    *

类访问器,用于访问类节点。

  • org.objectweb.asm.ClassReader
    1
    2
    3
    4
    5
    6
    7
    /**
    * A Java class parser to make a {@link ClassVisitor} visit an existing class.
    * This class parses a byte array conforming to the Java class file format and
    * calls the appropriate visit methods of a given class visitor for each field,
    * method and bytecode instruction encountered.
    *
    ...

用于读入类字节码相关内容,并提供内部元素访问方法。

  • org.objectweb.asm.tree.ClassNode
    1
    2
    3
    /**
    * A node that represents a class.
    *

表示一个类,与class文件结构对应,继承于org.objectweb.asm.ClassVisitor。

  • org.objectweb.asm.ClassWriter
    1
    2
    3
    4
    5
    6
    7
    /**
    * A {@link ClassVisitor} that generates classes in bytecode form. More
    * precisely this visitor generates a byte array conforming to the Java class
    * file format. It can be used alone, to generate a Java class "from scratch",
    * or with one or more {@link ClassReader ClassReader} and adapter class visitor
    * to generate a modified class from one or more existing Java classes.
    *

类写入器,并提供了类逐行扫描的过程。

流程简述
  1. 读入类字节码内容内容

    1
    ClassReader cr = new ClassReader(classByte);
  2. 生成类节点对象ClassNode、类编写器ClassWriter(用于使用AbstractInsnNode)

    1
    2
    3
    4
    5
    6
    7
    this.classNode = new ClassNode();
    // 初始化classNode-进行类结构粗扫描
    classReader.accept(classNode, ClassReader.SKIP_DEBUG);
    // 初始化ASMClassAdapter-进行方法内逐行扫描
    ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
    classAdapter = new ASMClassAdapter(cw);
    classReader.accept(classAdapter, ClassReader.SKIP_DEBUG);

3.遍历Fields

1
2
3
4
5
6
7
8
9
10
11
12
private Set<String> getClassInFields() {
Set<String> result = new HashSet<>();
// 0.0 获得所有属性
List<FieldNode> fieldList = this.classNode.fields;
for (FieldNode fieldNode : fieldList) {
// 1.0 获得类名
String classNameStr = Type.getType(fieldNode.desc).getClassName();
// 2.0 将真实类名填写到类列表内
result.add(ASMConstant.pickClassName(classNameStr));
}
return result;
}

4.遍历所有Methods

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private Set<String> getClassInMethods() {
Set<String> result = new HashSet<>();
// 0.0 获得所有方法
List<MethodNode> methodList = this.classNode.methods;
for (MethodNode methodNode : methodList) {
// 1.1 如果是构造方法,则捕获当前类类型
if(ASMConstant.METHOD_TYPE_INIT.equals(methodNode.name)){
result.add(ASMConstant.pickClassName(this.classNode.name.replaceAll("\\/", "\\.")));
}

// 1.2 提取参数
for(Type argumentType : Type.getArgumentTypes(methodNode.desc)){
result.add(ASMConstant.pickClassName(argumentType.getClassName()));
}

// 1.3 提取局部变量
List<LocalVariableNode> lvNodeList = methodNode.localVariables;
for (LocalVariableNode lvn : lvNodeList) {
result.add(ASMConstant.pickClassName(Type.getType(lvn.desc).getClassName()));
}
}
return result;
}

5.Methods内逐行遍历(前一步只对Methods声明作出遍历)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public void visitInsn(int opcode) {
Iterator<AbstractInsnNode> itr = this.instructions.iterator(0);
while (itr.hasNext()) {
AbstractInsnNode insn = itr.next();
switch (insn.getType()) {
case AbstractInsnNode.FIELD_INSN:
// 1.0 方法内局部变量
String fieldInsnDesc = ((FieldInsnNode) insn).desc;
// 1.1 获得类名
String classNameStr = ASMConstant.pickClassName(Type.getType(fieldInsnDesc).getClassName());
// 1.2 将真实类名填写到类列表内
this.classSet.add(classNameStr);
break;
case AbstractInsnNode.METHOD_INSN:
// 2.0 方法内调用的方法
String methodInsnOwner = ((MethodInsnNode) insn).owner;
// 2.1 名称转换 this.classSet.add(ASMConstant.pickClassName(methodInsnOwner.replaceAll("\\/", "\\.")));
break;
case AbstractInsnNode.TYPE_INSN:
// 3.0方法内调用的类型
String typeInsnDesc = ((TypeInsnNode) insn).desc;
this.classSet.add(ASMConstant.pickClassName(typeInsnDesc.replaceAll("\\/", "\\.")));
break;
...

经历以上过程汇总后,即可获得某类字节码中涉及的所有类列表,再与明确构建的白名单列表作出比对,即可验证类使用范畴的安全性。


心得

工程实践积累经验,不但需要在过程中提高熟练度,更需要从实践中抽象模型、整理思路、总结理论,追求技术中由“技”到“术”获得的沉淀。