[译] 分析让 iOS 崩溃的字符

February 23, 2018

iOS 又出现一个特殊字符崩溃 bug 了。基本上只要把这字符显示到任意系统文本框里就会让当前应用崩溃。为了不让我的浏览器崩溃,我在 Spoltlight 里复制粘贴试过了。

有问题的编码是 U+0C1C U+0C4D U+0C1E U+200C U+0C3E,一个泰卢固语字符的编码:辅音 ja (జ),一个 virama ( ్ ),辅音 nya (ఞ),一个零宽不连字(zero-width non-joiner)和元音 aa ( ా)。

我很好奇这编码到底有什么特别,所以开始了研究。

刚开始时,我猜问题出于 <ja,virama,nya> 这组编码。在很多印度文里这编码会组成一个特殊的连字(天城文是 ज्ञ),通常都认为这是一个独立的字母。然而这连字在泰卢固语里并没有什么 “特别”。

另外,经过一些实验,我发现泰卢固语里多个辅音和一个元音的任意结合也会出现这个 bug,只要元音不是 ai ( ై) 就行。

这么说的话问题应该是出在零宽不连字上。在印度文里 <辅音,virama,辅音,元音> 是很常见的编码,但零宽不连字在元音之前就不是那么常见了(除了孟加拉语和奥里雅语,下文会再提到)。

然后我看到孟加拉语另一个编码也同样会崩溃。

这个编码是 U+09B8 U+09CD U+09B0 U+200C U+09C1,辅音 so (স),一个 virama( ্ ),辅音 ro (র),一个零宽不连字,还有元音 u ( ু)。

在我们继续深入之前,先来了解下印度文是怎么工作的:

印度文和复辅音

印度文属于元音附标文字:是一类以辅音字母为主体,元音以附加符号形式标出的表音文字。默认情况下,辅音都自带一个元音。举个例子,क 读 “kuh“(kə,也经常写成 ”ka“),但我能改变它的元音让它变成 के (像 “okay” 里的 “ka”)或 का(“kaa”,像 “car” 里的)。

通常来说,默认的元音是 /ə/ 音(孟加拉语里更常见的是 /o/ 音)。

因为有默认元音,所以需要一个组合辅音的方法。例如,单词 “ski”,你不能写作 स + की(sa + ki = “saki”),你必须写成 स्की。这里先是把 स 的元音去掉,然后和 की 结合变成一个复辅音连字。

你也可以它写成 स्‌की。附在 स 后面的小尾巴就是一个 “virama”:通常这意味着 “把它的元音去掉”。显式的 virama 有时会用在不能简单地组成连字的方式上,例如,ङ्‌ठ ,因为没有简明的方式表示 ङ 和 ठ 确实连在了一起。而且有一些文字也更喜欢用显式的 virama,例如,“ski” 在马来西亚语里是写成 സ്കീ,那个小半月就是显式的 virama。

unicode 里,virama 字符总是用于组成复辅音。所以 स्की 是写成 <स, ्,क, ी>,或者说是 <sa,virama,ka,i>。如果字体支持这复辅音,它就会显示成一个连字,否则它就需要用显式 virama。

对于天城文和孟加拉语,通常来说,复辅音的第一个辅音会稍微改变一下字形而第二个辅音会保持不变。当然也有例外 —— 有时它们会组成一个全新的字形(क + ष = क्ष),有时两个辅音都会变(ड + ड = ड्ड, द + म = द्म, द + ब = द्ब)。最后一个例子的结合形式看起来应该是这样子:

conjuncts

探究孟加拉语的例子

现在有趣的是,不像泰卢固语的那样,孟加拉语的崩溃似乎只会发生在第二个辅音是 র(ro)时。无论第一个辅音或元音是什么我都能复现这个 bug,除了元音 ো (o)或者 ৌ (au)。

包括天城文在内,র 在一些印度文里是一个有趣的辅音。在天城文里,它像是 र (ra)。在组成复辅音时都是它在改变形状。如果它在另一个辅音之前,它会变成很小的像羽毛般的一笔,例如 र्क (rka) 里的。而马拉地语里,那一笔也会看起来像一个尖牙,像 र्‍क 里的。作为一个词尾辅音,它也能像一个小脚,和 क्र (kra) 的一样。对于没有竖线的字母,它又会像一个插入符,比如 ठ्र (thra)。

基本来说,在组成复辅音时大部分辅音都会保留它们一部分的形状,而 र 就不是。对 र 来说更特别的一点是,就算它是连字里第二个辅音也这样 —— 像我前面提到的,大部分复辅音里第二个辅音都会保持不变。但总有例外,不过那些例子通常都比较特殊,只有 र 的所有复辅音都是这样。

孟加拉语也是相似的,র 作为第二个辅音就会像触手一样加在第一个辅音上。例如,প + র (po + ro) 变成 প্র (pro)。

但不仅 র 会这样,辅音 “jo” 也是的。প + য (po + jo) 会组成 প্য (pjo),这时 য 会变为一条叫作 “jophola” 的波浪线。

所以我也用 য 试了下,结果在孟加拉语里出现 য 的复辅音也同样会崩溃!所以孟加拉语的规律是 <辅音,virama,র 或者 য,零宽不连字,元音>,只要元音不是 ো 或 ো 。

后缀辅音(Suffix-joining consonants)

我们在一步步接近了。至少对孟加拉语来说,崩溃通常出现在第二个辅音和第一个辅音结合在一起但形状没多大改变的情况下。

事实上,对于泰卢固语也是一样的!泰卢固语的复辅音通常会维持原来的辅音形状,并且把第二个辅音加在下面。

例如,原来会导溃的字符有 జ + ఞ,合起来看像是 జ్ఞ。第一个字母并没有什么改变,但第二个有一些。

由此我们可以猜测这也可能发生在天城文的 र 上。它确实也是!U+0915 U+094D U+0930 U+200C U+093E 是 <क, ्, र, 零宽不连字, ा> (<ka, virama, ra, 零宽不连字, aa>) ,也是会导致崩溃的编码之一。

但肯定不只这些吧?例如,孟加拉语里崩溃也会出现在 “kro” + 零宽不连字 + 元音的情况,并且含有 “kro” (ক্র = ক + র = ko + ro) 的复辅音会让前缀或后缀的辅音都发生改变。但 द्ब 或 द्ब 却没有崩溃。这看起来像是特定的字母才会引起崩溃,并不是复辅音本身导致的。

更深入地说,原因可能是对于很多字体来说(或许是现在用的),这些辅音在元音前变成了后缀辅音(这个词是原作者编的)。这可能跟 OpenType 的 pstfvatu 特性有关。

例如,编码 virama + क 会变成 ्क,它应该显示一个占位符后面跟着一个 क。

但对于 र 来说,virama + र 是 ्र,对我来说看起来应该是这样:

virama-ra

事实上,对于其它一些辅音来说也是这样的。在我看来, ्र ্র ্য ్ఞ ్క (分别是:天城文 virama-ra,孟加拉语 virama-ro,孟加拉语 virama-jo,泰卢固语 virama-nya,泰卢固语 virama-ka)全都是显示成“后缀辅音”:

virama-consonant

(对于泰卢固语辅音全都是这样,不只上面列出的这些)。

有趣的一点是 <र, virama, र, 零宽不连字, 元音> 并不会崩溃,因为 र-virama-र 用的是第一个 र 的前缀附加形式(र्र)。র/ৰ/য 和它们自己的组合也是这样。因为在这些例子里,virama 对于左边来说更像是一个 “贴图(sticker)”,它并不会导致崩溃。(h/t hackbunny通过脚本遍历所有情况发现了这一点)。

埃纳德语同样也有 “后缀辅音”,但不知道什么原因我并不能用它来触发崩溃。

零宽不连字

零宽不连字是一个有趣的东西。没有它崩溃不会出现,但像我前面提到的,在大多数印度文里元音前的零宽不连字并没有什么作用。在印度文里,零宽不连字在 virama 之后可以用来强制进行变音显示(在本文里我用它来显示 स्‌की),然而这里并不是那样用的。

特别是孟加拉语和奥里雅语,元音前的零宽不连字是用来显示该元音的不同形状(例如 রু 和 র‌ু),然而这个 bug 看起来对只有一个形状的元音才有效,另外这个 bug 在其它文字就算不是这种情况也一样会出现。

那些例外的元音也挺有趣。它们基本上都是都由两个部分组成的。

归纳

那么最后,所有导致崩溃的情形总结如下:

对于天城文,孟加拉语和泰卢固语,任何 <辅音1,virama,辅音2,零宽不连字,元音> 的编码,如果:

  • 辅音2是作为后缀附加的(pstf/vatu),像 र, র, য 还有全部泰卢固语辅音
  • 辅音1不是 र/র 等会叠用的字母(或其变体,如 ৰ)
  • 元音不是有两部分字形的,如 ై, ো 或 ৌ。

都会崩溃。那么现在还剩下一个问题:

为什么崩溃对埃纳德语没效?或者像高棉语,它也有一个叫做 “coeng” 类似 virama 的东西。

总结

总的来说,对于为什么会这样我还没有一个可靠的猜测,我也很想知道你们是怎么看的,目前我想到的是因为 virama 更亲于左边的辅音而不是右边的,让处理 virama 后面有零宽不连字的算法出了问题,认为零宽不连字要作用于 virama (事实上不应该这样,因为中间还有一个辅音),导致内存里一些数目对不上让缓冲区溢出了或什么别的问题。

有趣的是我可以在浏览器里通过点击那些字符串来稳定复现崩溃。

另外,在 Spotlight 里有时会先显示了一段时间才崩溃,说明这崩溃可能不是那么确定性的,或者说它是发生在渲染之后的一些处理里。看调用栈,这崩溃好像会发生在不同的地方,就像是再次访问了损坏后的内存一样。

很期待你们对这问题更深入的见解。