原文:https://googleprojectzero.blogspot.com/2018/07/drawing-outside-box-precision-issues-in.html

在这篇文章中,我们将为读者介绍一种非常罕见的漏洞类型,通常情况下,这种类型的漏洞只会影响图形渲染库(当然,有时候它们也会出现在其他类型的软件中)。之所以会出现这种问题,究其根本原因,是因为精度误差能够令应用程序所做的安全性假设失效,致使其转而使用有限精度算法。

这种类型的漏洞与整数溢出非常相似,主要区别在于:对于整数溢出来说,是出现在这样的算术运算过程中——由于运算的结果太大而无法以给定的精度来准确表示。对于本文描述的安全漏洞来说,则出现在这样的算术运算过程中——因运算结果或结果的一部分太小而无法以给定的精度准确表示。

在运算结果对安全非常敏感的运算中,这类问题通常是由浮点运算所致,但是,正如我们稍后将演示的那样,在某些情况下,这类安全问题也可能出现在整数运算中。

下面,先让我们来看一个简单的例子:

float a = 100000000;
 float b = 1;
 float c = a + b;

对于上述计算,如果采用任意精度的话,结果应该是100000001。但是,由于浮点数通常只允许使用24位精度,因此,结果实际上将是100000000。所以,如果应用程序假设a>0并且b>0的话(通常都会这样合理地假设),则意味着a+b>a,那么,这种假设就很可能导致问题。

在上面的例子中,a和b之间的差异非常大,并且b在计算结果中完全消失;然而,即使差异较小,也会发生精度误差,例如

float a = 1000;
 float b = 1.1111111;
 float c = a + b;

上述计算的结果将是1001.111084,而非1001.1111111,而后者才是准确的结果。在这种情况下,虽然b的只有很小一部分丢失,但即使如此,有时也会带来有趣的后果。

在上面的示例中,使用的是浮点类型,如果使用双浮点类型的话,虽然能够获得更精确的计算结果,但是,类似的精度错误仍然无法消除。

在本文的其余部分中,我们将展示几个有安全影响的精度问题例子。这些安全问题由两位Project Zero成员独立发现的:一位是Mark Brand,他研究的对象是SwiftShader,它是OpenGL的软件实现,用于Chrome浏览器;另一位是Ivan Fratric,他考察的对象是Chrome和Firefox中使用的Skia图形库。

SwiftShader


SwiftShader是“OpenGL ES和Direct3D 9图形API基于CPU的高性能实现”。所有平台上的Chrome浏览器都将其作为备选渲染库,用于突破图形硬件或驱动程序的限制,使其可以在更广泛的设备上应用WebGL和其他高级JavaScript渲染API。

SwiftShader中的代码需要对通常由GPU执行的各种操作进行模拟。例如,通常由GPU执行的一种操作是upscaling,即将一段小的源纹理绘制到更大的区域,例如铺满屏幕。这需要使用非整数值计算内存索引,实际上,漏洞往往就出现在这里。

就像最初的漏洞报告中所指出的那样,我们在这里看到的代码,未必就是实际运行的代码--SwiftShader会使用基于LLVM的JIT引擎在运行时对关键代码进行性能优化,而优化后的代码,要比优化前的代码更难以理解,但是两者都包含相同的错误,因此,我们不妨先从简单的代码入手,即先考察未经优化的代码。实际上,这些代码就是用于在渲染过程中将像素从一个表面复制到另一个表面的复制循环:

source->lockInternal((int)sRect.x0, (int)sRect.y0, sRect.slice, sw::LOCK_READONLY, sw::PUBLIC);
 dest->lockInternal(dRect.x0, dRect.y0, dRect.slice, sw::LOCK_WRITEONLY, sw::PUBLIC);

 float w = sRect.width() / dRect.width();
 float h = sRect.height() / dRect.height();

 const float xStart = sRect.x0 + 0.5f * w;
 float y = sRect.y0 + 0.5f * h;
 float x = xStart;

 for(int j = dRect.y0; j < dRect.y1; j++)
 {
   x = xStart;

   for(int i = dRect.x0; i < dRect.x1; i++)
   {
     // FIXME: Support RGBA mask
     dest->copyInternal(source, i, j, x, y, options.filter);

     x += w;
   }

   y += h;
 }

 source->unlockInternal();
 dest->unlockInternal();
}

那么,这段代码有什么问题吗?我们知道,在进入这个函数之前就已经完成了所有的边界检查,并且在dRect中使用 (i, j)和sRect中使用(x, y)调用copyInternal也都是安全的。

实际上,在上面的示例中,出现的精度误差是下舍入导致的——就本例而言,还不足以产生让我们感兴趣的安全漏洞。我们能否利用浮点数的精度误差,获得一个比正确值更大的值,从而导致(x,y)的值大于预期值呢?

如果我们仔细阅读代码,就会发现,开发人员的本意为:

for(int j = dRect.y0; j < dRect.y1; j++)
 {
   for(int i = dRect.x0; i < dRect.x1; i++)
   {
     x = xStart + (i * w);
     Y = yStart + (j * h);
     dest->copyInternal(source, i, j, x, y, options.filter);
   }
 }

即使使用这种方式,仍然会存在精度误差——但是,由于没有进行迭代计算,误差无法传播,这样的话,最终的预期精度误差的大小就是稳定的,并且与操作数大小成正比。相反,如果代码执行迭代计算的话,误差就会迅速传播,像滚雪球一样,越滚越大。

对于浮点计算来说,有多种方法都可以估算其最大误差;如果你真的、真的需要避免进行额外的边界检查的话,那么可以使用这种方法,但是一定确保这种方法的最大误差具有足够的安全边际量。说实话,对于这个问题来说,这可能是一种太过复杂且容易出错的解决方案。在这里,我们想要做的,是寻找一些用来展示这种类型的漏洞的“带病”数值,因此,这种方式显然不太合适;所以,接下来,我们会采取一种简单粗暴的方法。

我们觉得,使用乘法的实现的话,得到的结果将是大致准确的,而带有迭代加法的实现得到的结果则不那么准确。鉴于可能的输入空间很小(Chrome不允许使用宽度或高度大于8192的纹理),因此,不妨对源宽度与目标宽度的所有比率进行蛮力搜索,从而对这两种算法做个比对,看看结果有什么不同。(请注意,SwiftShader也限制我们使用偶数)。这样,我们得到的结果为5828,8132;下面是对这两种方法的比较(左侧使用的是迭代加法,右侧使用的是乘法):

0:    1.075012 1.075012
1:    1.791687 1.791687
...
1000: 717.749878 717.749878   到目前为止,这两个值仍然完全相等(就所示的精度来说) 
1001: 718.466553 718.466553
...
2046: 1467.391724 1467.391724 从这里开始,第一个有效位误差开始出现,但请注意 
2047: 1468.108398 1468.108521“不精确”的结果小于更精确的结果。
...
2856: 2047.898315 2047.898438
2857: 2048.614990 2048.614990 在这里,两个计算结果再次一致,简单地说,
2858: 2049.331787 2049.331787 从这里开始,精度误差总是倾向于产生
2859: 2050.048584 2050.048340 更大的结果而不是更精确的计算。
...
8129: 5827.567871 5826.924805
8130: 5828.284668 5827.641602
8131: 5829.001465 5828.358398 对于最后一个索引来说,现在的差值已经非常大了,因此进行  int转换后,会产生一个oob索引

(还要注意,这种“安全”计算也会有误差;只是误差没有被传播,换句话说,该误差与输入误差的大小成正比,因此,我们期望输入误差“越小越好”。)

我们确实可以看到,乘法算法的结果不会超过边界;但是,迭代算法则可能返回输入纹理边界之外的索引!

因此,我们在纹理分配结束后读取了整整一行像素——这很容易通过WebGL泄露给JavaScript代码。请大家继续关注即将发布的博客文章,届时,我们将使用这个漏洞以及SwiftShader中的另一个安全漏洞,通过JavaScript代码来控制GPU进程。

Skia


Skia是一个图形库,适用于Chrome、Firefox浏览器和Android平台等各种环境。在Web浏览器中,当使用CanvasRenderingContext2D绘制canvas元素或绘制SVG图像时,就会用到该图形库。虽然在绘制其他HTML元素时也可以使用Skia,但是从安全角度来看,我们对canvas元素和SVG图像更感兴趣,因为它们能够更加直接地控制图形库绘制的对象。

Skia能够绘制的最复杂的对象(换句话说,从安全角度来看,我们最感兴趣的东西)是路径。路径是由诸如线之类的元素组成的对象,同时,也可以包括更复杂的曲线,特别是二次或三次样条 "样条")。

由于在Skia中采用的是软件绘图算法,因此,其中很可能存在精度问题,并且,一旦出现这种安全漏洞,通常会导致越界写入问题。

要理解为什么会出现这些问题,先让我们假设在内存中有一个图像(可以将其视为一个缓冲区,大小=宽度x高度x色彩值长度)。通常,在绘制坐标为(x,y)且颜色值为c的像素时,需要确保该像素位于相应图像的空间内,特别是x和y需要满足下列条件:0 <= x < width 且 0 <= y < height。如果不进行上述检查的话,就能够将像素写到为图像分配的缓冲区的边界之外。在计算机图形学中,确保将绘图限制在某个区域称为剪辑。

那么,问题出在哪里呢?我们知道,对每个像素进行剪辑检查是会耗费大量的CPU周期的,但是别忘了,Skia一直都是视速度为生命的。实际上,Skia并不会对每个像素都进行剪辑检查,而是首先对整个对象(例如,线条、路径或正在绘制的任何其他类型的对象)进行剪辑检查。根据剪辑检查情况,会有三种可能的结果:

  • 对象完全位于绘图区域之外:绘图函数不会绘制任何内容,并立即返回。
  • 对象部分位于绘图区域之内:绘图函数进行绘制,同时进行逐像素剪辑检查(通常依赖于SkRectClipBlitter)。
  • 整个对象都位于绘图区域之内:绘图函数直接将对象绘制到缓冲区中,并且不进行逐像素剪辑检查。

存在问题的是最后一种情况,因为它仅对每个对象进行剪辑检查,并且更为精确的逐像素检查被禁用了。这就意味着,如果在逐对象剪辑检查和像素绘制之间存在精度问题,并且如果精度问题导致像素坐标超出绘图区域的话,则可能引发安全漏洞。

我们可以看到,针对每个对象进行的剪辑检查有时候会弃用逐像素检查,比如:

  • hair_path(仅绘制路径而不进行填充的函数)中,剪辑最初设置为null(这将禁用剪辑检查)。仅当路径的边界不适合绘图区域时(路径边界根据绘图选项向上舍入并扩展1或2),才会设置剪辑。将路径边界扩展1似乎是一个相当大的安全余量,但实际上,这个值是最不可能的一个安全值,因为使用抗锯齿特性绘制对象时,会出现绘制到附近的像素上面的情况。
  • SkScan::FillPath(用于在关闭抗锯齿特性的情况下填充路径的函数)中,路径的边界首先由kConservativeRoundBias进行扩展,并通过四舍五入获得“保守”路径边界。然后,为当前路径创建SkScanClipper对象。正如我们在SkScanClipper的定义中所看到的,如果路径边界的x坐标在绘图区域之外,或者如果irPreClipped为真(仅当路径坐标非常大时才会出现这种情况),它将仅使用SkRectClipBlitter。

在其他绘图函数中,也可以看到类似的模式。

在我们深入研究这些问题之前,首先快速回顾一下Skia使用的各种数字格式是非常有帮助的:

  • SkFDot6虽然定义为整数,但它实际上是一个定点数,小数点左边26位,小数点右边6位。例如,SkFDot6值0x00000001表示数字1/64。
  • SkFixed也是一个定点数,小数点左边16位,小数点右边16位。例如,SkFixed值0x00000001表示1/2**16)

整数转换为浮点数时出现的精度误差


对于这个问题,最初是去年我们对Firefox进行DOM模糊测试时发现的。当时,Skia的写出越界问题引起了我们的注意,因此,我们又做了进一步的调查。实证明,根本原因是Skia在某些地方将浮点转换为整数的方式存在误差所致。在进行逐路径剪辑检查时,使用该函数时会对取值较低的坐标(边界框的左侧和顶部的坐标)进行舍入处理:

static inline int round_down_to_int(SkScalar x) {
   double xx = x;
   xx -= 0.5;
   return (int)ceil(xx);
}

通过阅读代码,我们不难发现,对于大于-0.5的数字,它将返回一个大于或等于零的数字(这是通过路径级剪辑检查所必需的)。但是,在代码的另一部分中,特别是SkEdge::setLine中,如果定义了SK_RASTERIZE_EVEN_ROUNDING(在Firefox中就是这种情况),则使用以下函数将浮点数取整为不同的整数:

inline SkFDot6 SkScalarRoundToFDot6(SkScalar x, int shift = 0)
{
   union {
       double fDouble;
       int32_t fBits[2];
   } tmp;
   int fractionalBits = 6 + shift;
   double magic = (1LL << (52 - (fractionalBits))) * 1.5;

   tmp.fDouble = SkScalarToDouble(x) + magic;
#ifdef SK_CPU_BENDIAN
   return tmp.fBits[1];
#else
   return tmp.fBits[0];
#endif
}

现在,让我们来看看对于数字-0.499,这两个函数的返回值分别是什么。对于这个数字来说,round_down_to_int函数将会返回0(该值总能通过剪辑检查),而SkScalarRoundToFDot6函数则会返回-32,它对应于-0.5,所以,我们最终得到的数字实际上比我们开始时的数字要小。

然而,这并不是唯一的问题,因为在SkEdge :: setLine中还有另外一个产生精度误差的地方。

分数相乘时出现的精度误差


SkEdge :: setLine会调用SkFixedMul,其定义为:

static inline SkFixed(SkFixed a, SkFixed b) {
   return (SkFixed)((int64_t)a * b >> 16);
}

该函数用于对两个SkFixed数进行相乘,然而,使用该函数对负数进行相乘时会出现问题。下面,我们来举例说明,首先假设



,如果我们求这两个数的积的话,结果为

。但是,由于SkFixedMul的工作方式,特别是由于使用右移操作将计算结果转换回SkFixed格式,我们最终得到的结果却是0xFFFFFFFF,这是

的SkFixed表示形式。因此,我们最终得到的实际结果要远远大于预期值。

由于SkEdge::setLine使用这个乘法运算的结果来调整这里的初始线点的x坐标,因此,我们可以利用SkFixedMul的这个问题来产生额外的误差,最多为位像素的1/64,从而越过绘图区域的边界。

通过组合利用前两个问题,可以使一条线的x坐标变得足够小(小于-0.5),这样,当一个用小数表示的值通过舍入操作变为整数时,Skia将试图在x=-1的坐标处进行绘制,但是这些位置显然位于图像的范围之外。这样,就会出现如原始漏洞报告中所说的越界写漏洞。在Firefox浏览器中,我们可以成功利用该漏洞,为此,只需绘制一幅其坐标具有上述问题的SVG图像即可。

结束语


在本文中,我们为读者介绍了由于图形渲染库精度问题而引发的安全漏洞。其中,我们讲解了SwiftShader图形库和Skia图像库中的精度误差问题,同时,还解释了整数转换为浮点数,以及分数相乘时出现的误差问题。在本文的下篇中,我们将为读者进一步深入讲解其他方面的精度误差问题,以及它们所带来的安全隐患。

点击收藏 | 0 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖