分析単純な不正なテキスト メッセージや電子メールを送信するだけで、iOS 6 の iPhone や iPad、および OS X 10.8 を実行している Mac を混乱させることができると判明したため、多くの人が冷笑して袖を隠しています。
脆弱な Apple オペレーティング システムの CoreText コンポーネントが特定の Unicode 文字シーケンスを画面上にレンダリングしようとすると、バグがトリガーされます。カーネルは、Web ブラウザー、メッセージ クライアント、Twitter アプリなど、CoreText を使用して不適切な文字列を表示しようとした実行中のプログラムを強制終了することで反応します。
人々がこの特殊文字をツイートしたり、ウェブ記事のコメント欄に投稿したり、テキストメッセージで送ったりして、大笑いが巻き起こった。ファンボーイたちの不満の叫びに歓喜する人々もいた。(Facebook社は、この文字列がステータスアップデートとして投稿されるのをブロックせざるを得なかった。)
しかし、そのバグはどのように機能したのでしょうか?調べてみると、かなり簡単に説明できる、ごく普通のプログラミング上のミスだったようです。脆弱なコードはおそらく何年も前から出回っていたのでしょう。6ヶ月前には一部の人が気づいていて、4月のHack In The Boxカンファレンスのプレゼンテーションのスライド[PDF]にも登場していました。当時はほとんど誰も気に留めませんでしたが、週末にかけてロシアのウェブサイトにトリガーとなる文字列が出現したことで、ウェブ上で拡散し始めました。
長すぎて読めなかった:要約
AppleのCoreTextレンダリングシステムは、配列のインデックスと文字列の長さの受け渡しに符号付き整数を使用しています。負の長さ(-1)がライブラリ関数にチェックなしで渡され、ライブラリ関数はそれを符号なし長整数として配列の境界を設定します。これにより、ライブラリは配列の末尾を超えて未割り当てのメモリへの読み込みを試み、致命的な例外が発生します。
ソフトウェアを逆アセンブルしてデバッグする経験があれば、これから説明する内容はすぐに理解できるでしょう。MacやiThingの内部で何が起こっているのか興味がある方は、ぜひ読み進めてください。
まず、クラッシュの様子を見てみましょう。これらの手順はすべて、OS X 10.8.4 を搭載した 64 ビット Mac で発生しました。ターミナルアプリに 5 つの 16 ビット Unicode 文字からなる特定の文字列を表示させることで、プログラムはカーネルによって即座に強制終了され、OS が生成した以下の障害レポートが表示されます。
これらのクラッシュログを解析するのは少々面倒ですが、まず最初に気づくのは、プロセッサがlibvDSP.dylibというライブラリ内で実行されていたことです。具体的には、障害が発生した際に、そのライブラリの117462バイト目の命令が実行されていたのです。これはスタックバックトレースの一番上、レポートの30行目に記述されており、バグが発生する前にコードが呼び出した関数のシーケンスを記述しようとしています。
libvDSP (お使いのコンピュータの/System/Library/ファイルシステム階層の奥深くにあります)を、非常に便利なリバースエンジニアリングツールHopperで開くと、コンパイルされたマシンコード(エラーの原因となったもの)を見ることができます。下のスクリーンショットをご覧ください。117462バイト目( 16進数で1cad6)のエラーが発生した命令がハイライト表示されています(クリックで拡大)。
この命令、addsd xmm1, qword [ds:rdi+rsi] は、 rdiレジスタとrsiレジスタを加算して算出されたメモリアドレスから、64ビット値をxmm1レジスタにロードしようとします。クラッシュログには、アドレス0x00007fa95cc00008でEXC_BAD_ACCESSエラーが発生し、強制終了したことが記録されています。つまり、この命令は該当のメモリアドレスからデータを読み取ろうとしましたが、アクセス不可とマークされていました。メモリマップの該当部分にアクセスすることは想定されていないため、プログラムが何らかの損害を受ける前にカーネルによって強制終了されます。
実際、ログを見ると、クラッシュ直前にターミナルにアドレス0x00007fa95cc00000までの2048KBのメモリが割り当てられていたことがわかります。プログラムがこの制限を超え、致命的な例外が発生した可能性が高いようです。クラッシュログをスレッドの状態までスクロールすると、クラッシュ時のCPUレジスタの値を確認できます。
スレッド 0 が X86 スレッド状態 (64 ビット) でクラッシュしました: rax: 0x0000000000000030 rbx: 0x00007fa95bcc5010 rcx: 0xfffffffffffc3e0a rdx: 0x00007fff5c862d60 rdi: 0x00007fa95cbffff8 rsi: 0x0000000000000010 rbp: 0x00007fff5c862d70 rsp: 0x00007fff5c862d58 r8: 0xffffffffffffffff r9: 0x00007fa95c83e1e8 [...] r15: 0x00000000000000002
Hopper は、 0x1cad2と0x1cae7の間でループが実行されていることを強調しています。このループは、データを読み取り、 xmm0、xmm1、xmm2の3つの累積合計に加算し、レジスタrcxの値を減算します。この減算は、ゼロを超えた時点でループから抜け出すまで行われます。つまり、 rcx をループのカウントダウンとして効果的に使用しています。各反復処理において、rdi は増加し、rsi は一定です。前述のように、これら2つを加算することで、読み取り元のアドレスが計算されます。 rdiとrsiを加算すると、アドレス0x7fa95cc00008が生成され、(上記のように)障害がトリガーされることがわかります。
つまり、rdiが大きくなりすぎて、割り当てられていないメモリを読み込まざるを得なくなっているということです。取得しようとしているデータのアドレスはrcxによってのみ制限されるため、これはrcxが大きすぎることを示しています。rdiが無効なメモリにプッシュする前に rcx がゼロにならないのです。そして、確かにその通りです。クラッシュ時点で0xfffffffffffc3e0aの時点で、 rcx はプログラム全体に割り当てられたメモリをはるかに超える、実現不可能なほど大きな値からカウントダウンしていました。実際にはかなり小さいはずです。何が間違っているのでしょうか?
libvDSPバイナリのデバッグ情報を分析した結果、Hopperは障害発生時にライブラリのvDSP_sveD()関数内で動作していたことを明らかにしました。Appleはこの関数について以下のドキュメントを公開しています。
void vDSP_sveD (double *__vDSP_A、vDSP_Stride __vDSP_I、double *__vDSP_C、vDSP_Length __vDSP_N);
これは倍精度浮動小数点数の配列を合計するために使用されます。Mac OS Xで使用されるSystem V AMD64 ABIに従って、関数の入力パラメータをこのコンパイル済みコードで使用されるレジスタにマッピングしてみましょう。
rdi = double *__vDSP_A 入力値の配列へのポインタ rsi = vDSP_Stride __vDSP_I ストライド。これについては気にしないでください。rdx = double *__vDSP_C 結果を格納する場所へのポインタ rcx = vDSP_Length __vDSP_N 合計する配列要素の数
変数型vDSP_Lengthは次のように定義されます。
typedef unsigned long vDSP_Length;
したがって、rcx には、処理する配列要素の数である正の整数のみが指定されるはずであり、これがループ内でゼロまでカウントダウンされる理由です。
CoreTextコンポーネント内部の何かが、途方もなく大きな__vDSP_N値を使ってvDSP_sveD()を呼び出しています。ライブラリ関数は、呼び出し元が処理内容を把握していると想定しているため、この数値の妥当性チェックを一切行いません。このループカウンタ値が大きいため、rdiは入力配列__vDSP_Aの境界を超え、アプリがクラッシュします。
では、このライブラリ関数はどこで呼び出されているのでしょうか?スタックトレースを詳しく見ていくと、プロセッサがCoreTextコンポーネントのTRun::TRun関数、具体的には850バイト目にあることがわかります。そこには、 TStorageRange::SetStorageSubRange()という別のCoreText関数を呼び出す命令があり、これを逆アセンブルすると以下のようになります(クリックして拡大)。
このSetStorageSubRange()関数はCoreTextの内部関数であり、公開ドキュメントには記載されていません。しかし、この関数が0x274f9でvDSP_sveD()を呼び出していることが分かります。これが前述のクラッシュの原因です。
破滅の危機に瀕した関数呼び出しの直前、rcx(libvDSPに渡される不正な配列の長さの値を保持)は、0x274f6にあるr8レジスタから値を取得します。関数vDSP_sveD() はr8 を変更しないため、これは内部を覗き見るのに非常に便利です。前述のクラッシュダンプから、 r8の内容を確認できます。r8 は、 vDSP_sveD()が呼び出される直前に、ループカウントダウンレジスタrcxを初期化するために使用されます。
そして、その値はとてつもなく大きい。実際、これは符号なし64ビットレジスタの最大値なので、配列の境界を超えてクラッシュするのも当然だ。
r8: 0xffffffffffffffff
その符号なし整数は、符号付き 10 進整数として表現されると、2 の補数の規則に従って -1 になります。
そのため、vDSP_sveD()は CoreText のSetStorageSubRange()によって負の配列長で呼び出されますが、これはライブラリ関数が想定している値ではありません。vDSP_sveD() は正の値のみを取るように定義されています。SetStorageSubRange ()は libvDSP の合計関数を正しく呼び出していません。
この負の数はどこから来るのでしょうか?SetStorageSubRange()の内部に戻ると、関数の開始位置0x2744e付近でr8レジスタ( vDSP_sveD( )のrcxを初期化するために使用)にrdxの値が渡されていることがわかります。SetStorageSubRange ()内のすべての可能なコードパスを辿ると、 r8の値が負の値であるか、内部フラグビットがクリアされている場合、その初期値から変化しないことがわかります。したがって、 vDSP_sveD()に渡される -1 はrdxから来ています。
このrdxレジスタはSetStorageSubRange()の入力パラメータです。コードから判断すると、rdiに格納されている関数の最初のパラメータは、 vDSP_sveD()の合計計算結果を格納する64ビット変数へのポインタです。残りの2つのエントリパラメータはrdxとrsiです。
デバッグ情報によると、SetStorageSubRange() は入力の一つとしてCFRange構造体を受け取ります。この構造体はここで公開定義されています。
struct CFRange { CFIndex 位置; CFIndex 長さ; };
この構造体は、「バッファ内の文字など、コンテナ内の連続した項目の範囲」を表します。この構造体には2つのCFIndex変数が含まれます。1つはlocationというラベルで、配列の開始位置を定義します。もう1つはlengthというラベルで、配列内の対象となる項目の数を表します。
CFIndexは次のように定義されます。
typedef signed long CFIndex;
つまり、SetStorageSubRange()関数の2番目のパラメータはCFRange構造体で、位置をrsiに、長さをrdx(負の整数)に格納しているようです。そのため、 SetStorageSubRange()を呼び出した何らかの関数は文字列の長さとして -1 を渡しており、これが上記のクラッシュの原因となっています。
(長さが符号付き long 型であることを考えると、-1 は有効な数値である可能性があります。Apple の Core レンダリング システムは、ソフトウェア全体で符号付きCFIndex値を「配列のインデックスとして、また、カウント、サイズ、長さのパラメータと戻り値として」使用します。)
もう一度戻ってみると、CoreTextのTrun::Trun()という重要な関数に辿り着きます。この関数は0x25d57でSetStorageSubRange()を呼び出しています。以下は逆アセンブリコードです(クリックで拡大)。
クリックして拡大
0x25d25のところで興味深いことが起こります。レジスタrbxがインクリメントされ、その値がrdxにコピーされます。次に、 rdxからr15レジスタの値が減算されます。その結果のrdxの値はSetStorageSubRange()に渡されますが、これは先ほど見たように -1 です。そして、 r15 の値は、この瞬間からクラッシュに至るまで奇跡的に保持されていたため、分かります。つまり、レジスタダンプは次のようになります。
r15: 0x00000000000000002
逆算すると、rbx はインクリメントされる直前にゼロでなければなりません。そうしないと、rdxが -1 となり、後でトランプのカードを崩すことになります。スパゲッティアセンブリコードをTRun::TRun()まで辿っていくと、rbxは事前に計算された長さ、つまり処理する文字数または Unicode グリフ数である可能性が高いようです。
ジェイルブレイクされたデバイス向けにこのバグを修正しようとしているフィリッポ・ビガレッラ氏は、CoreTextの公開ドキュメントにあるCTRunGetGlyphCount()関数が、特殊なUnicode of Deathシーケンスによって-1という値を返すことを発見しました。この関数は、「グリフラン(同じ属性と方向を共有する連続したグリフの集合)」内のUnicodeグリフの数を返すはずです。
負の値は現時点では適切ではないようです。これは、キリル文字とアラビア文字を短く無意味に混ぜ合わせたUnicodeのキラーストリング(文字列)が、オペレーティングシステムに文字列の長さがゼロまたは負であると判断させる文字列であることを示唆しています。初心者の方のために説明すると、Unicodeとは、皆さんがきっとご存知の従来のASCII文字セットを超えた文字を格納および処理する方法であり、アラビア語やアジア言語の文字から数学記号まで、あらゆる文字を表現できます。
Unicodeは、左から右、右から左への方向を設定したり、複数のグリフを組み合わせてカスタムグリフを作成したりできます。勘か、あるいは熟考された推測かはさておき、フォントレンダラーに関するあなたの経験からすると、CoreTextが奇妙なUnicodeに混乱し、負の長さを計算してしまう可能性も否定できません。何か良いアイデアをお持ちの方がいらっしゃいましたら、ぜひご連絡ください。
バグを引き起こす文字シーケンスの 1 つに、単純なスペース (ASCII コード 0x20) が含まれます。これは、スタック トレースに表示される空白処理コードに関連している可能性があります。
最後に
CTRunGetGlyphCount()のコードは、ラン内のグリフ数を計算しません。代わりに、事前に初期化されているはずのデータ構造から値を取得します。Bigarella 氏によると、CoreText がレンダリングするテキスト行を表すCTRunRefオブジェクトを作成する際に、グリフ数の誤計算が発生する可能性があるとのことです。
ここで手がかりは途絶え、獲物はTRun::Trun()とその先の長い草むらの中に消え去ってしまう。次のステップは、デバッガーを起動し、無効なグリフ数がどのように計算されているかが明らかになるまで、ゆっくりと実行をステップ実行していくことだ。パッチ未適用のバグのためにキラーなUnicode文字列を作成するレシピを公開するのは、そもそも最良のアイデアではないかもしれない。
一方で、現状の欠陥は、ユーザーのプログラムをクラッシュさせる以外には悪用できないようです。配列の終端の読み取り障害を利用して、より深刻な問題を引き起こすのは非常に困難です。
この脆弱性は、公開されている最新のiOSバージョンを搭載した32ビットARM搭載のiPhone、iPod、iPadでも発生する可能性があります。つまり、このバグは特定のアーキテクチャに固有のものではないということです。バッファオーバーランは、上記の64ビットではなく32ビットの範囲内で発生する点を除けば、ほぼ同じように動作します。
このアプリを壊滅させるコーディングエラーは、iOS 7とMac OS X 10.9(コードネーム:Mavericks)には存在せず、どちらも近日中に正式リリースされる予定です。El RegはAppleに連絡を取り、旧バージョンのソフトウェアが修正されるかどうかを確認しましたが、コメントは得られませんでした。
記事に誤字脱字があるように、ソフトウェアにもバグはあります。Appleのサポートフォーラムには、Unicodeに起因するクラッシュに関する苦情が寄せられています。®