1. 为什么需要字节码修改器?

就像我们平时在 IDE 中编写代码那样,我们主要通过 System.out 来展示运行结果(在不下断点的情况下),异常信息也是直接打印到控制台来看的。所以我们要能以相同的方式让客户端可以得到他想要运行的代码的运行结果,这就需要我们将程序往标准输出(System.err 和 System.out)中打印的信息收集起来返回给客户端。

在实现JavaClassExecuter 的过程中,收集代码执行结果时会出现两个问题:

  1. 标准输出设备是整个虚拟机进程全局共享的资源,如果使用 System.setOut()System.setErr() 方法把输出流重定向到自己定义的 PrintStream 对象上固然可以收集输出信息,但这在多线程的情况下显然是不可取的,因为有可能将其他线程的结果也收集了。
  2. 除此之外,允许客户端程序随便调用 System 的方法还存在着安全隐患,比如如果客户端发来的程序中调用了:System.exit(0) 等方法,这对服务器来说是十分危险的。

因此,我们考虑将程序中的 System 类都替换成一个自己写的 HackSystem 类,这样既可以解决多线程下收集输出结果的问题,又可以将 System 中比较危险的调用都改写成抛出异常,以达到禁止客户端程序调用 System 类的目的

注意:HackSystem 类收集客户端程序的运行结果的过程还涉及到一个并发问题,我们将在后面详细讲解 HackSystem 类时进行说明,这一节主要讲如何将客户端程序中对 System 的调用替换为对 HackSystem 的调用。

2. 将 System 替换为 HackSystem 的思路

那么如何将客户端程序中对 System 的调用替换为对 HackSystem 的调用呢?当然不能直接修改客户端发来的程序的源代码字符串了,这既不优雅,操作也十分的繁琐。我们采用了一种“高级”的方法,即直接在字节码中,把要执行的类对 System 的符号引用替换为我们准备的 HackSystem 的符号引用,因此我们需要一个字节码修改器,这个字节码修改器完成如下流程:

  • 遍历字节码常量池中的所有符号引用,找到 java/lang/System
  • java/lang/System 替换为 org/olexec/execute/HackSystem

要想完成以上 2 步操作,首先我们要了解类文件的结构,这样我们才能找到类对 System 的符号引用的位置,并且知道替换的方法;其次,我们还需要一个字节数组修改工具 ByteUtils 帮助我们修改存储字节码的字节数组。

2.1 类文件结构

这里,为了不影响阅读的流畅性,我们只简单介绍一下我们会用到的有关类文件结构的内容。以下是一个 Class 文件具有的基本结构的简单图示:
03-字节码修改器 - 图1
Class 文件是一组以 8 位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在 Class 文件中,中间没有任何分隔符。Java 虚拟机规范规定 Class 文件采用一种类似 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数,我们之后也主要对这两种类型的数据类型进行解析。

  • 无符号数: 无符号数属于基本数据类型,以 u1、u2、u4、u8 分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数,可以用它来描述数字、索引引用、数量值或 utf-8 编码的字符串值。
  • 表: 表是由多个无符号数或其他表为数据项构成的复合数据类型,名称上都以 _info 结尾。

Class 文件的头 8 个字节:
Class 文件的头 8 个字节是魔数和版本号,其中头 4 个字节是魔数,也就是 0xCAFEBABE,它可以用来确定这个文件是否为一个能被虚拟机接受的 Class 文件(这通过扩展名来识别文件类型要安全,毕竟扩展名是可以随便修改的)。

后 4 个字节则是当前 Class 文件的版本号,其中第 5、6 个字节是次版本号,第 7、8 个字节是主版本号。

常量池:
从第 9 个字节开始,就是常量池的入口,常量池是 Class 文件中:

  • 与其他项目关联最多的的数据类型;
  • 占用 Class 文件空间最大的数据项目;
  • Class 文件中第一个出现的表类型数据项目。

常量池的开始的两个字节,也就是第 9、10 个字节,放置一个 u2 类型的数据,标识常量池中常量的数量 cpc (constant_pool_count)。

这个计数值有一个十分特殊的地方,就是它是从 1 开始而不是从 0 开始的,也就是说如果 cpc = 22,那么代表常量池中有 21 项常量,索引值为 1 ~ 21,第 0 项常量被空出来,为了满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”时,将让这个索引值指向 0 即可。

常量池中记录的是代码出现过的所有 token(类名,成员变量名等,也是我们接下来要修改的地方)以及符号引用(方法引用,成员变量引用等),主要包括以下两大类常量:

  • 字面量:接近于 Java 语言层面的常量概念,包括
    • 文本字符串
    • 声明为 final 的常量值
  • 符号引用:以一组符号来描述所引用的目标,包括
    • 类和接口的全限定名
    • 字段的名称和描述符
    • 方法的名称和描述符

常量池中的每一项常量都通过一个表来存储。目前一共有 14 种常量,不过麻烦的地方就在于,这 14 种常量类型每一种都有自己的结构,我们在这里只详细介绍两种:CONSTANT_Class_info 和 CONSTANT_Utf8_info。

CONSTANT_Class_info 的存储结构为:

  1. ... [ tag=7 ] [ name_index ] ...
  2. ... [ 1 ] [ 2 ] ...

其中,tag 是标志位,用来区分常量类型的,tag = 7 就表示接下来的这个表是一个 CONSTANT_Class_info,name_index 是一个索引值,指向常量池中的一个 CONSTANT_Utf8_info 类型的常量所在的索引值,CONSTANT_Utf8_info 类型常量一般被用来描述类的全限定名、方法名和字段名。它的存储结构如下:

  1. ... [ tag=1 ] [ 当前常量的长度 len ] [ 常量的符号引用的字符串值 ] ...
  2. ... [ 1 ] [ 2 ] [ len ] ...

在本项目中,我们需要修改的就是值为 java/lang/SystemCONSTANT_Utf8_info 的常量,因为在类加载的过程中,虚拟机会将常量池中的『符号引用』替换为『直接引用』,而 java/lang/System 就是用来寻找其方法的直接引用的关键所在,我们只要将 java/lang/System 修改为我们的类的全限定名,就可以在运行时将通过 System.xxx 运行的方法偷偷的替换为我们的方法。

因为我们需要修改的内容在常量池中,所以我们就介绍到常量池为止,不再介绍 Class 文件中后面的部分了,接下来我们将要介绍修改字节码常量池时会用到的一个处理字节数组的小工具:ByteUtils。

2.2 ByteUtils 工具

这个小工具主要有以下几个功能:

  • byte to int
  • int to byte
  • byte to String
  • String to byte
  • 替换字节数组中的部分字节

    3. 实现字节码修改器

    介绍完会用到的基础知识,接下来就是本篇的重头戏:实现字节码修改器。通过之前的说明,我们可以通过以下流程完成我们的字节码修改器:
  1. 取出常量池中的常量的个数 cpc;
  2. 遍历常量池中 cpc 个常量,检查 tag = 1 的 CONSTANT_Utf8_info 常量;
  3. 找到存储的常量值为 java/lang/System 的常量,把它替换为 org/olexec/execute/HackSystem(通过将字节码转换成字符串,然后与org/olexec/execute/HackSystem进行对比);
  4. 因为只可能有一个值为 java/lang/System 的 CONSTANT_Utf8_info 常量,所以找到之后可以立即返回修改后的字节码。

最后,我们还有一个小问题需要注意一下,问题是有关“换行符”的,在结果字符串中,换行是通过 System.lineSeparator() 表示的,可是将结果返回给客户端,客户端是用 html 来展示结果的,因此我们需要将运行结果字符串中所有的 System.lineSeparator() 都替换为 <br/>,我们在 RunCodeController 中添加如下一行代码完成这步操作:

  1. runResult = runResult.replaceAll(System.lineSeparator(), "<br/>");