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

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

将样条线转换为线段时出现的浮点精度误差


在绘制路径时,Skia会将所有非线性曲线(圆锥曲线、二次和三次样条)转换为线段。不足为奇的是,这些转换也会受到精度误差的影响。

样条线到线段的转换在很多地方都会发生,但最容易出现浮点精度误差的是hair_quad(用于绘制二次曲线)函数和hair_cubic(用于绘制三次曲线)函数。这两个函数都是从hair_path调用的,这一点已经在上面提到过了。由于在处理三次样条时,会出现更大的精度误差,所以,在这里只考察三次曲线的情形。

在逼近该样条曲线时,首先会利用SkCubicCoeff计算三次曲线的各个系数。其中,我们最感兴趣的部分如下所示:

fA = P3 + three * (P1 - P2) - P0;
fB = three * (P2 - times_2(P1) + P0);
fC = three * (P1 - P0);
fD = P0;

其中P1、P2和P3是输入点,fA、fB、fC和fD是输出系数。然后,使用hair_cubic计算线段点,具体代码如下所示:

const Sk2s dt(SK_Scalar1 / lines);
Sk2s t(0);

...

Sk2s A = coeff.fA;
Sk2s B = coeff.fB;
Sk2s C = coeff.fC;
Sk2s D = coeff.fD;
for (int i = 1; i < lines; ++i) {
   t = t + dt;
   Sk2s p = ((A * t + B) * t + C) * t + D;
   p.store(&tmp[i]);
}

其中,p是输出点,而lines则是我们用来逼近曲线的线段数。根据样条曲线的长度的不同,一根三次样条曲线最多可以用512条线段进行逼近。

很明显,这里的计算并不十分精确。在本文的后面部分,当x和y坐标进行相同的计算时,我们只考察x坐标部分的相关计算。

我们假设绘图区域的宽度是1000像素。因为hair_path用于支持抗锯齿的绘制路径,所以,我们需要确保路径的所有点都介于1到999之间,实际上,这些工作都是在最初的路径级剪辑检查中完成的。下面让我们考虑以下坐标,这些坐标都通过了该项检查:

p0 = 1.501923
p1 = 998.468811
p2 = 998.998779
p3 = 999.000000

对于这些点来说,相应的系数如下所示:

a = 995.908203
b = -2989.310547
c = 2990.900879
d = 1.501923

如果使用更大的精度完成相同的计算,你就会发现这里的数字并不太精确。接下来,让我们看看如果用512个线段逼近样条曲线会是什么结果。实际上,这会得到513个x坐标:

0: 1.501923
1: 7.332130
2: 13.139574
3: 18.924301
4: 24.686356
5: 30.425781
...
500: 998.986389
501: 998.989563
502: 998.992126
503: 998.994141
504: 998.995972
505: 998.997314
506: 998.998291
507: 998.999084
508: 998.999695
509: 998.999878
510: 999.000000
511: 999.000244
512: 999.000000

我们可以看到,x坐标在不断增加,并且在点511处,已经明显超出了“安全”区域,其值也超过了999。

实际上,这还不足以触发越界写漏洞,为何?这主要归功于Skia绘制抗锯齿线的工作方式,实际上,我们至少需要在剪辑区域之外画1/64像素,这样它才会触发安全漏洞。然而,在这种情况下,关于精度误差的一个有趣的事情是:绘图区域越大,可能出现的误差就越大。

因此,让我们考虑一个32767像素的绘图区域(Chrome中的最大画布尺寸)。然后,首先进行剪辑检查,看看所有路径点是否全部位于区间[1,32766]内。现在,让我们考察以下各点:

p0 = 1.7490234375
p1 = 32765.9902343750
p2 = 32766.000000
p3 = 32766.000000

相应的系数为:

a = 32764.222656
b = -98292.687500
c = 98292.726562
d = 1.749023

相应的线性逼近值为:

0: 1.74902343
1: 193.352295
2: 384.207123
3: 574.314941
4: 763.677246
5: 952.295532

505: 32765.925781
506: 32765.957031
507: 32765.976562
508: 32765.992188
509: 32766.003906
510: 32766.003906
511: 32766.015625
512: 32766.000000

如您所见,我们在索引511处就明显越出了边界。

然而,这个bug并不能用于触发内存损坏,至少在最新版本的skia中不会触发这个问题——对于Skia来说,这是非常幸运的;但是,对于野心勃勃的攻击者来说,这又是非常遗憾的。究其原因,还是在于SkDrawTiler。每当Skia使用SkBitmapDevice(而非使用GPU设备)进行渲染、绘图区域在所有维度上都大于8191像素、并且不是一次绘制整个图像的时候,Skia就会将其拆分为大小(最大)为8191x8191像素的多个图块 。这种做法是在今年3月份引进的,之所以这样做,并非出于安全考虑,而是为了支持更大的绘图表面。不过,它仍然有效地阻止了针对这个漏洞的利用,同时,也阻止了针对表面大于8191而致使精度误差足够大的其他情况下的漏洞利用。

尽管如此,这个漏洞在3月之前却是可利用的,我们认为,这个例子能够帮助我们很好地阐释精度误差的概念。

将样条线转换为线段时的整数精度误差


在绘制(就这里来说,是填充)路径时,需要使用线段来逼近样条线,这时候就会受到精度误差的影响,并且在这种情况下,精度误差漏洞是可以利用的。有趣的是,这里的精度误差并不是出现在浮点运算中,而是出现在定点运算中。

该问题发生在SkQuadraticEdge::setQuadraticWithoutUpdate和SkCubicEdge::setCubicWithoutUpdate中。为了简单起见,这里还是只关注三次样条曲线,并且仅考察曲线的x坐标。

SkCubicEdge::setCubicWithoutUpdate中,首先会将曲线坐标转换为SkFDot6类型。然后,计算曲线在起始点处的一阶、二阶、三阶导数对应的参数:

SkFixed B = SkFDot6UpShift(3 * (x1 - x0), upShift);
SkFixed C = SkFDot6UpShift(3 * (x0 - x1 - x1 + x2), upShift);
SkFixed D = SkFDot6UpShift(x3 + 3 * (x1 - x2) - x0, upShift);

fCx     = SkFDot6ToFixed(x0);
fCDx    = B + (C >> shift) + (D >> 2*shift);    // biased by shift
fCDDx   = 2*C + (3*D >> (shift - 1));           // biased by 2*shift
fCDDDx  = 3*D >> (shift - 1);                   // biased by 2*shift

其中,x0、x1、x2和x3是定义三次样条的4个点的x坐标,而shift和upShift则取决于曲线的长度(这对应于用来逼近曲线的线段的数量)。为简单起见,我们可以假设shift=upShift=6(最大可能值)。

现在,让我们输入一些非常简单的值,来看看会发生什么情况:

x0 = -30
x1 = -31
x2 = -31
x3 = -31

请注意,x0、x1、x2和x3属于skfdot6类型,因此,值-30对应于-0.46875,而值-31对应于-0.484375。虽然这些值虽然接近于-0.5,但并不是等于,因此,在四舍五入时非常安全。现在,让我们看看计算出来的参数值:

B = -192
C = 192
D = -64

fCx = -30720
fCDx = -190
fCDDx = 378
fCDDDx = -6

你知道问题出在哪里吗?提示:它位于fCDx的计算公式中。

当计算fCDx(曲线的一阶导数)时,D的值需要右移12位。然而,D的值太小了,无法精确地做到这一点,并且由于D是负的,所以实际进行的右移操作为:

D >> 2*shift

结果为-1,这个数值的绝对值比预期结果的要大。(因为D是SkFixed类型,所以,它的实际值是-0.0009765625,当移位被解释为除以4096时,得到的结果为-2.384185e-07)。正因为如此,最终计算出来的fCDx结果(负值,即-190)的绝对值,会大于正确结果(即-189.015)的绝对值。

之后,在计算线段的x值时会用到fCDx的值,具体见skcubiedge::updateCubic中如下所示的这行代码:

newx    = oldx + (fCDx >> dshift);

当使用64个线段(该算法的最大值)逼近样条曲线时,相应的x值为(表示为索引、整数的SkFixed值和对应的浮点值):

0:    -30720   -0.46875
1:    -30768   -0.469482
2:    -30815   -0.470200
3:    -30860   -0.470886
4:    -30904   -0.471558
5:    -30947   -0.472214
...
31:   -31683   -0.483444
32:   -31700   -0.483704
33:   -31716   -0.483948
34:   -31732   -0.484192
35:   -31747   -0.484421
36:   -31762   -0.484650
37:   -31776   -0.484863
38:   -31790   -0.485077
...
60:   -32005   -0.488358
61:   -32013   -0.488480
62:   -32021   -0.488602
63:   -32029   -0.488724
64:   -32037   -0.488846

如您所见,对于第35个点,x值(-0.484421)最终会小于最小输入点(-0.484375),并且对于后面的点来说,这种趋势依旧延续。该值虽然在取整后会变为0,但还存在另一个问题。

在SkCubicEdge::updateCubic中计算的x值将传递给SkEdge::updateLine,并且在下面的代码中,它们会从SkFixed类型转换为SkFDot6类型:

x0 >>= 10;
x1 >>= 10;

又一次右移!举例来说,当SkFixed值-31747进行移位后,我们最终得到一个skfdot6类型值-32,即-0.5。

现在,我们可以使用上面在“分数相乘时的出现的精度误差”一节中描述的技巧使该值小于-0.5,从而突破图像的边界。换句话说,我们可以在绘制路径时,让Skia对坐标x=-1的位置进行渲染。

但是,我们能利用该漏洞做些什么呢?


通常,由于Skia将会将图像的像素按行组织后作为一个整体进行内存分配(类似于大多数其他软件针对位图的内存分配方式),所以,有些情况下可能会发生精度问题。假设我们有一个大小为width x height的图像,同时,假设我们只能超出该范围外一个像素:

  • 如果向y=-1或y=height的位置进行绘图的话,会立即触发堆越界写问题
  • 如果向y=0且x=-1的位置进行绘图的话,会立即导致堆下溢1个像素
  • 如果向x=width且y=height-1的位置进行绘图的话,会立即导致堆溢出1个像素
  • 如果向y>0且x=-1的位置进行绘图的话,会导致像素“溢出”到上一个图像行
  • 如果向x=height且y < height-1的位置进行绘图的话,会导致像素“溢出”到下一个图像行

就本文来说,我们遇到的是第4中情形,但遗憾的是,我们无法在y=0的情况向x=1的位置进行渲染,因为精度误差需要随着y值的增长而积累。

我们来看看下面的SVG图像:

<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
<style>
body {
margin-top: 0px;
margin-right: 0px;
margin-bottom: 0px;
margin-left: 0px
}
</style>
<path d="M -0.46875 -0.484375 C -0.484375 -0.484375, -0.484375 -0.484375, -0.484375 100 L 1 100 L 1 -0.484375" fill="red" shape-rendering="crispEdges" />
</svg>

如果在未修复该漏洞的版本的Firefox中呈现该图像的话,将看到如下所示的内容。请注意,该SVG图像的像素大部分位于屏幕左侧,但右侧也有少许红色像素。这是因为,根据图像的分配方式,向x=-1且y=row的位置进行渲染,实际上就是向x=width-1且y=row-1的位置进行渲染。

一旦打开这个SVG图像,就会触发Firefox中的Skia精度问题。如果你仔细观察,你会发现图像右侧有一些红色像素。那些人是怎么到那儿的? :)

需要说明的是,我们使用的是Mozilla公司的Firefox浏览器,而不是Google公司的Chrome浏览器,主要是在SVG的渲染机制(特别是:Skia似乎一次绘制整个图像,而Chrome则使用额外的平铺操作)方面,Firefox更适合演示该问题。但是,Chrome和Firefox都会受该问题的影响。

但是,这个问题除了可以呈现一个有趣的图像外,是否存在实际的安全影响呢?这时,就该SkARGB32_Shader_Blitter上场了(每当Skia中的颜色应用着色器效果时,就会用到它)。SkARGB32_Shader_Blitter的特殊之处在于:会分配一个与单个图像行大小相同的临时缓冲区。当SkARGB32_Shader_Blitter::blitH用于绘制整个图像行时,如果我们可以使其从x=-1到x=width-1(或从x=0到x=width)进行渲染的话,这时,它就需要向缓冲区写入width + 1个像素,而问题在于——该缓冲区只能容纳width个像素,从而导致缓冲区溢出,这一点可以参考漏洞报告中的ASan日志。

请注意Chrome和Firefox的PoC如何包含带有linearGradient元素的SVG图像的——线性梯度专门用于选择SkARGB32_Shader_Blitter,而不是直接向图像绘制像素,这只会导致像素溢出到前一行。

这个漏洞的另一个不足之处在于,只有在关闭抗锯齿特性的情况下绘制(更具体地说,是填充)路径时,才会触发该漏洞。由于目前无法在关闭抗锯齿的情况下绘制HTML画布元素的路径(有一个imageSmoothingEnabled属性,但它仅适用于绘制图像,而不适用于绘制路径),因此,必须使用shape-rendering="crispEdges" 的SVG图像来触发该漏洞。

我们公布的Skia中的所有因精度问题所导致的漏洞,都是通过增加kConservativeRoundBias来进行修复的。虽然当前的偏差值足以覆盖我们所知道的最大精度误差,但不容忽视的是,其他地方仍有可能出现精度问题。

结束语


虽然大多数软件产品并不会出现精确问题(如本文所述),然而,一旦出现该问题,就会引起非常严重的后果。为了防止出现该问题,我们应该:

  • 在计算结果对安全性敏感的情况下,请勿使用浮点运算。如果必须使用浮点运算的话,则要确保可能的最大精度误差不大于某个安全范围。在某些情况下,可以使用区间运算来确定最大精度误差。或者,针对计算结果而非输入进行相应的安全检查。
  • 使用整数运算时,需要密切关注所有可能降低计算结果精度的运算,例如除法和右移。

不幸的是,目前还没有很好的方法可以揪出这些漏洞。我们刚开始研究Skia时,我们最初的想法,是在绘图算法上使用符号执行来查找可能导致绘制越界的输入值,因为从表面上看,符号执行似乎非常适合查找这种漏洞。然而,实践证明,这种做法存在太多问题:大多数工具不支持浮点符号变量,即使仅针对最简单的线绘制算法的整数部分进行符号执行,我们也无法在合理的时间内完成这项任务。

最后,我们还是投向了各种老派方法的怀抱:对源代码进行人工审计,模糊测试(尤其是针对接近图像边界的值),在某些情况下,当我们已经识别出可能存在问题的代码区域时,甚至会通过蛮力方式遍历所有可能值。

您是否知道精度误差会导致安全问题的其他情形呢?请在评论中告诉我们,我们将感激不尽。

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