「通常の算術型変換」
足し算、引き算、かけ算、割り算など「通常の算術」演算においてオペランドが異なる型である場合、正しく計算を行うために共通の型に揃える必要が生じます。C99は「6.3.1.8 通常の算術型変換」でそのルールを定めています。プログラマが意識しなくても、コンパイラはこのルールに従って型のバランスを取るような処理を行っているのです。整数に関する脆弱性を作り込まないために、しっかり覚えておきたいルールですのでこれを機会に復習しておきましょう。
(C99引用)
算術型のオペランドをもつ多くの演算子は、同じ方法でオペランドの型変換を行い、結果の型を決める。型変換は、オペランドと結果の共通の実数型(common real type)を決めるために行う。
(中略)
通常の算術型変換の規則は、次による。
(中略)
1. 両方のオペランドが同じ型をもつ場合、更なる型変換は行わない。
2. そうでない場合、両方のオペランドが符号付き整数型をもつ、又は両方のオペランドが符号無し整数型をもつならば、整数変換順位の低い方の型を、高い方の型に変換する。
3. そうでない場合、符号無し整数型をもつオペランドが、他方のオペランドの整数変換順位より高い又は等しい順位を持つならば、符号付き整数型をもつオペランドを、符号無し整数型をもつオペランドの型に変換する。
4. そうでない場合、符号付き整数型をもつオペランドの型が、符号無し整数型をもつオペランドの型のすべての値を表現できるならば、符号無し整数型をもつオペランドを、符号付き整数型をもつオペランドの型に変換する。
5. そうでない場合、両方のオペランドを、符号付き整数型をもつオペランドの型に対応する符号無し整数型に変換する。
「整数変換順位」という言葉が出てきましたが、これはビット幅の広い型ほど高い順位を持つことを定めたルールです。型が同じであれば、符号付き/符号無しにかかわらず順位は同じになります。以下の表に各型とその順位の関係を示しておきます。
今回注目しているコードでは、上記ルールの3番目に従うことになります。つまり、int32型オペランドとuint32型オペランドの加算なので、両方のオペランドをuint32型に統一したうえで演算が行われ、uint32型の値を得ます。
int32型をuint32型に「変換する」とはどういうことでしょうか? また、int32型オペランドが負の値だった場合はどうなるのでしょうか? uint32型は符号無しの型であり負の値を表現できません。すると本来負の値になるべき場合の演算結果は何か意味のない値になってしまうのでしょうか?
いやいや、そんなことはありません。符号無し整数型を符号付き整数型に変換したときの値をC99では、ビット表現が変わらない形になるように規定しています。すなわち、符号無し整数型へのキャストと同等の結果になるように、C99の規定は作られています。この場合、演算結果のuint32型の値をもう一度int32型とみなせば意図したとおりの値が得られることが分かります。
分かりやすく4ビット整数型で考えてみましょう。符号無し4ビット整数型では 0から15、符号付き4ビット整数型では-8から7までが表現可能です。
int4 si = -8; uint4 ui = 7;
(si + ui) の数学的な計算結果は-1です。一方、Cの変換ルールに照して考えると、siをuint4型の値に変換した値は8となり、(8 + 7) = 15となります。15という値のビット表現はint4型の値としては-1です。つまり、最終的な計算結果のビット表現は数学的な計算結果のビット表現と一致するのです。
演算結果が負の値になる場合まで考慮に入れるのであれば、演算結果をあらためてint32型として扱うようにコードを書く必要があります。例えば、明示的にキャストする、あるいは適切な型の変数に一度代入するなどの処理です。
さて、元の式に戻りましょう。次にuint32型として得られた演算結果とポインタの加算演算が行われます。C99におけるポインタと整数型との間の加減算の意味については、以前の記事(ポインタ演算は正しく使用する C/C++セキュアコーディング入門(2))でとりあげたことがありますが、ここではint型の大きさが32ビット、ポインタの大きさが64ビットであるLP64データモデル環境でこの演算がどのように実装されるのかが問題となります。
ポインタも整数型も、どちらもCPUから見れば単なる数値データですから、それらの加減算は単純に加減算を行うアセンブラコードに翻訳されることでしょう。ただし、64ビット長データと32ビット長データの加減算なので、32ビット長データを64ビットに変換して64ビット長データ同士の加減算が行われます。このとき、uint32型が符号無しの型のため、ゼロ拡張が行われています。つまり、上位32ビットには単純に0が埋められます。
ここでゼロ拡張を行うことが問題です。演算結果のuint32型データは本来負の値も取り得るint32型の値として扱うべきデータでした。しかし、64ビットデータとしてゼロ拡張してしまうと、もはやビット表現としても本来の値とは異ったものになってしまいます。
toskew * 2 + w
が負の値となる状況では、最終的なポインタの値は小さくなるべきですが、逆に大きくなる方向に計算されてしまいます。その結果、あらぬ場所をアクセスすることとなり、プログラムはクラッシュしてしまうのです。実際、この問題が発見されたのはlibtiffを内部で使っているImageMagickで特定のTIFFファイルを処理する際にクラッシュするという報告が発端でした。
この問題は、2*toskew + wの結果をまずint32型変数incrに代入し、incrの値を使ってcp += incrを行うようコードを変更することで修正されました。以下にそのパッチの内容を示します。
uint32* cp2; + int32 incr = 2*toskew+w; ... + cp += incr; + cp2 += incr; ...
パッチで修正された式の型情報を示すと以下のようになります。
int32値 = 2 * int32値 + uint32値 ポインタ値 += int32値
ポインタ値 += int32値の式において、右辺は符号付き32ビット整数であり、コンパイラが出力するアセンブラコードでは、元の値の符号を保存する符号拡張を行って64ビット整数とするようになります。修正したコードをコンパイルすると、Intel系のアセンブラコードではcltq(Convert Long to Quad)命令が呼ばれていることが分かります。この命令は、%eaxを%raxに符号拡張する命令です。つまり64ビットに変換する際、ゼロ拡張ではなく符号拡張が行われるよう修正されていることが分かります。
なお、今回の脆弱性はLP64データモデルでは問題になりますが、ILP64データモデルではおそらく問題にならないでしょう。ILP64データモデルでは、int型も64ビット長となります。int32型やuint32型はint型よりも「小さい」型なので、算術演算の際にはまずそれぞれをint型に「変換」する「整数拡張」(integer promotion)と呼ばれる操作が行われます。整数拡張に関する説明は今回省略しましたが、どちらも元の値を表現する64ビット幅のint型に変換されます。結局、(int32値 + uint32値)という式の結果は符号付き型であるint値となります。さらに(ポインタ値 + int値)という演算についても、ビット幅を拡張する必要なく行えるため、数学的な計算結果と同じ値を得ることができます。