Return Bug的前世今生

关于RB(ReturnBug)的文章已经不是第一次写了,还记得当年写的“深入Return Bug,看看H2I的背后”这篇文章被转载了很多次,看过的人也不少,不过当时讲的还只是RB的一点皮毛并没有深入其真正的背后,现在8年过去了,我再次动笔写这个主题,缅怀的是我当年对魔兽的那份激情吧。时光荏苒,人烟变换,能再度回到WOW8上来发帖是我没想到的,既然回来了,那么就再度提笔写下这些当年没有完成的东西吧,这是我10年魔兽的一个总结,ReturnBug也是贯串魔兽始终的一个不应被遗忘的印记。

RB的前世——1.20时代

RB真正鼎盛的时期是1.20时代,那个经历了几个版本更替的魔兽,始终没有封闭这个漏洞,这明显是暴雪故意为之,因为他给地图的制作带来了很大便利,且没有危害(当时没发现)。当时的主要用法就是通过RB来获得单位,物品等HANDLE类型数据的数值,这个数值和缓存配合,可以给单位,物品,触发等等HANDLE绑定变量,自定义数据等等,相当于大大扩展JASS的灵活度,这些功能在现在都已经被GetHandleID和哈希表所取代,而在当时没有这些功能的JASS只有通过RB才能实现,因此RB的重要性不言而喻。

当时几乎所有的作者都知道RB,也都在用RB,其原理也几乎和表面看上去一样是个BUG,没有人去深究其实现原理,而注意力更多集中在应用上,或者是所返回数字的含义上。这么多年后的今天,在RB几乎被遗忘的时代,这篇文章就是要带领大家去看看RB背后真正的原理,它并不仅仅是一个BUG。

首先来看看1.20时代的RB实现方式:

function H2I takes handle h returns integer
    return h
    return 0
endfunction

这是一个最典型的RB,将HANDLE值转换为整数。

在1.20时代,这个写法是可以通过魔兽的语法检查的,BUG的原因就是利用语法检查只检查最后的返回值类型而不检查上面的返回值,因此显然用一个不同类型的变量作为整数返回,相当于是将HANDLE强制转换为了整数。那么这里其实就揭示了RB的真实面目,RB的功能就是——强制类型转换。

看似RB原理很简单,其实不然,说是BUG,其实BUG的只是语法检查而已,这个函数本身是符合JASS虚拟机运行原理的,并不是一个BUG。

JASS虚拟机其实就是一个执行JASS脚本的中间托管程序,可以类似于JAVA虚拟机等其他脚本语言的虚拟机,JASS脚本在运行时是先进行语法检查再转变成为虚拟机可以读取的字节码来运行的,字节码与JASS脚本的关系就像是汇编代码和高级语言之间的关系,一个是纯16进制数字,一个是可以读的脚本。

这里说RB对于虚拟机而言并不是BUG是因为如果1.20版本的JASS脚本通过了语法检查并转换为字节码的话,同样的字节码在1.20的虚拟机和1.24及以上的虚拟机中都能正常执行,对于虚拟机而言它是一段正常脚本而并不是BUG。所以这也揭示了暴雪封堵RB的一个事实,他们并没有从虚拟机的根源上来封堵RB,而仅仅是修改了语法检查,让这个写法通不过检查而已,这个近乎于懒惰的做法也为如今1.24以上版本可用的新RB埋下了伏笔。

下面就让我们来看看RB代码在内存中的真实面目——字节码:

下面的内容就是H2I函数在内存中的字节码,//后面为我加的注释

08070100 //08为获取函数的参数,07为参数类型,也就是上面函数中的HANDLE,01代表取第一个参数,这一行的意思是将函数H2I的参数1存入下一行的变量
00000E9B // E9B为变量的代号,这里就是上面函数中的变量h
0EC40700 //0E获取变量的内容,C4为临时寄存器编号,0707为参数类型HANDLE,这一行的意思是将下一行变量代号的变量内容存到C4号寄存器中
00000E9B // E9B为变量的代号,这里就是上面函数中的变量h
0D00C400 //0D代表设定返回值,C4为临时寄存器编号,这一行的意思就是将寄存器C4的内容作为返回值
00000000
27000000 //27代表返回,也就是函数截止返回结果
00000000
0CC50400 //0C代表向寄存器中存入常量,C5为临时寄存器编号,04为参数类型整数,这一行的意思就是将下一行的数据存入临时寄存器C5中
00000000 //整数常量0
0D00C500 //0D代表设定返回值,C4为临时寄存器编号,这一行的意思就是将寄存器C5的内容作为返回值
00000000
27000000 //27代表返回,也就是函数截止返回结果
00000000

我们可以看到,魔兽在执行H2I函数时,其实是执行了以上的一堆数字代码,其中奇数行的第一个字节也就是前两位数实际是操作类型,后面的数字有的是代表参数类型,有的是代表寄存器编号,有的是代表变量编号,而每个偶数行一般都是常量或者变量编号。

这里就涉及字节码格式了,实际上每两行是一个OPCODE,也就是我们常说的函数到上限了,实际上是指OPCODE到了30万的上限,OPCODE就是这里的每两行代码。

从代码里不难看出几点:

  1. 所有数据操作都要进过寄存器,如C4,C5,在JASS中,实际上一共有256个寄存器,在每个函数中是循环使用的,其中0号寄存器固定用来存放函数的返回值。
  2. 所有变量在字节码中都是一个数字代号,实际上这个代号并不是变量代号而是一个符号代号,符号在H2I函数里就是h,JASS运行中实际上会建立一个符号表,存放所有的函数名,变量名,所有在JASS中使用的全局变量,局部变量,甚至函数,在调用时都是通过其名称在符号表中的序号来调用的,也就相当于是按名字调用。
  3. JASS在设定返回值时并没有设定返回值类型,而是直接把某个寄存器的内容作为返回值。

这里第3点就是RB原理的关键了,因为虚拟机的这个设定原则,无论寄存器中存放的是什么类型的数据,只要把这个寄存器作为返回值,那么其内容都会作为函数返回的内容而类型则以函数返回类型为准,这样就做到了强制类型转换。因此,不论是1.20时代的RB还是1.24以后的RB,不管其表面写法如何,都只是在骗过语法检查而已,根本的原理还是一样的,就是JASS虚拟机在返回值部分不考虑类型。只要JASS脚本最终编译为字节码时是这样的结构,都能成功做到RB强制类型转换。这也是为什么我不看好暴雪会在后面的版本中彻底修复RB漏洞的原因,即使在魔兽最流行的时候,他们也没从根本上修改虚拟机,而只是改了语法检查而已,那么在魔兽已经成为小众游戏的今天,他们会大动虚拟机么?估计最多还是再从语法检查入手吧。

穿插一个花絮——UnionBug

有了我前面个讲的基础,我就顺带来讲一下另外一个著名的BUG——Union Bug。

其实Union Bug的原理更为简单,表面上看是同名的局部变量会覆盖全局变量,即便是类型不同也会覆盖,那么其背后原理呢?

前面讲过,在JASS执行时,实际上是通过变量名在符号表中的编号来调用变量的,那么当全局变量和局部变量同名是会发生什么情况呢? 比如上面的变量h,其编号是E9B,那么你会惊奇的发现,全局变量h和局部变量h的代号都是E9B,因为在符号表中只有一个叫h的表项。这样魔兽再读的时候怎么区分全局和局部呢?

实际上JASS在执行时会建立全局变量哈希表和局部变量哈希表,其中局部变量哈希表是以链表形式存在的,每个函数都有一个局部变量哈希表,同时可以链接到上一级函数的哈希表。因此在运行中读到变量h时,是通过E9B这个编号,查到变量名,再根据变量名先查当前函数的局部变量哈希表,再查全局变量哈希表,如果查到局部变量就会直接读取局部变量的数据,这也就是为什么同名的局部变量会覆盖全局变量的原因。

RB的今生——1.24及以上时代

1.24以上的RB是最近才爆出来的,这个也在WE吧引起了很大争论,到底要不要用这个危险的东西,其实对于现在的大多数作者而言,RB已经陌生了,可有可无,但是对于一个对魔兽有感情的WER来说,知道RB,会用RB还是很有意义的,这似乎是对这么多年来魔兽的一个总结。

下面要讲的是1.24以上RB的实现方法,原理上其实和上面给1.20的是一样的,他是有原理的,而并不像有些人理解的只是BUG而已。

globals
    code l__Code
    integer l__Int
end globals 

function setCode takes code c returnsnothing
   set l__Code=c
endfunction

function Typecast1 takes nothing returnsnothing
   local integer l__Code
   local code l__Int
endfunction

function C2I takes code c returns integer
   call setCode(c)
   return l__Code
   return 0
endfunction

上面的就是1.24以后RB的实现方式,其实最终原理还是让存有其他类型变量的寄存器按照整数来返回这个原理,从字节码角度没什么区别,但是为了避过语法检查,确实要麻烦不少,并且这里利用了Union Bug。

有了前面的基础这个讲起来就很简单了:

首先由同名但不同类型的全局变量和局部变量l__Code。

其次setCode函数只是一个简单的赋值,因为这个赋值操作在C2I函数中会造成语法错误,所以必须单独拿出来列一个函数避开检查。

然后是Magic函数Typecast1,内容仅仅只是定义同名局部变量,这个是利用了语法检查的BUG,再检查时会把之后出现的l__Code都定义为整数来检查,从而使C2I函数不出错,但是从编译后的字节码来说没这个其实是没意义的,最终都是靠变量编号来操作,因此这个函数的目的就是骗过语法检查,让后面的C2I函数能够编译成类似1.20的RB那样的字节码结构。

最后C2I函数实现,这里已经很显而易见了,由于return l__Code被正常编译为字节码了,而C2I函数中又没有局部变量l__Code,那么根据Union Bug原则,在查找不到局部变量名称时使用同名的全局变量,于是就把全局变量的l__Code传入到返回值寄存器中,完成了和1.20一样的RB操作。

这次先讲到这,也许讲的还不透彻,也许里面也有我理解错误的地方,欢迎大家指正,后面我会继续将RB来操作内存的原理,从而让大家知道,RB的背后,其真正的面目是什么样的。


由 rahxephon 于 2016-12-23 20:29:31 发表。