戸田 洋三(JPCERTコーディネーションセンター) [著]
ポインタ型データに対する加減算は普通の整数演算とは異なることに注意が必要です。今回はこのポインタ演算に関するコーディングエラーの例を見てみましょう。
勘違いしやすいポインタ演算
ポインタ型データは、メモリ上にあるオブジェクトの位置(メモリアドレス)を意味しています。この「メモリアドレス」は0から始まる整数値であり、ポインタ型データと整数データの間で加減算を行うことで必要なメモリアドレスを計算できるようになっています。しかし、ポインタ型データに対する加減算は普通の整数演算とは異なることに注意が必要です。今回はこのポインタ演算に関するコーディングエラーの例を見てみましょう。
ポインタ演算で加減算される値に注意
以下は、配列型データを操作するコード例ですが、一方は配列の記法を使い、もう一方はポインタを使ったコード例です。
■コード例1(配列の記法で操作)int i; int ar[N]; int br[N]; // ar[] を初期化するコードがここにあるとする for (i=0; i<N; i++){ br[i] = ar[i]+10; }■コード例2(ポインタを使って操作)
int i; int ar[N]; int br[N]; int *bp = br; // ar[] を初期化するコードがここにあるとする for (i=0; i<N; i++){ *(bp+i) = *(ar+i)+10; }
コード例2では、ポインタ型データに直接添字の値を加算してメモリアドレスを得ています。
この場合の(ar+i)や(bp+i)は、arやbpというメモリアドレスの数値にiという整数を加算したものではありません。(ar+i)は、arが参照しているメモリアドレスから順番にint型データが並んでいるものと想定し(配列)、そのi番めのデータの先頭を意味します。(bp+i)も同様です。つまり、数値の計算としては以下のような意味になります。
(bp+i) → (bpが指すメモリアドレス + sizeof(int) * i)
コード例2においてbpはint型を指すポインタだったため、コード中の(+i)は+(int型データのバイト数 * i)と解釈されます。int型が4バイトの大きさであれば、+(4 * i)となるわけですね。これがもし1000バイトの構造体へのポインタであれば、+(1000 * i)という意味になります。ポインタが指すデータ型がメモリ上に占める大きさが1バイトであれば、単純な数値計算と思っても同じ結果が得られてしまいますが、実際には、上記のように配列の添字と同等な意味を持っていることを忘れてはいけません。
バイト数単位で行うポインタの加減算
1バイトのデータへのポインタに関する演算が単純な数値計算と一致するというこの性質は、ポインタ演算がバイト数の加減算であると誤解して書いたコードを修正するときに利用することができます。以下で、そのような修正を行っている例を見てましょう。
以下は、OpenBSDで発生した例です(分かりやすいように、実際のコードを簡素化してあります)。
struct big { int i0; int i1; int i2; int i3; }; size_t skip = offsetof(struct big, i2); struct big *s = (struct big *)malloc(sizeof(struct big)); if(!s){ /* malloc() がエラーの時の処理 */ } memset(s + skip, 0, sizeof(struct big) - skip);
このコードは、s が参照する(struct big型)構造体データの、先頭から数えてskipバイト以降をゼロ初期化する意図でした。しかし、この構造体の大きさは1より大きいため、memset()でアクセスするメモリアドレス (s+skip)は、プログラマが想定しているよりも後ろを意味していたのです。その結果、このコードによってバッファオーバフローが発生してしまうことになりました。
プログラマの意図を汲んでこのコードを修正するには、(+skip)がバイト数の加算として扱われるようにしてやればよいでしょう。末尾のmemset()の部分を以下のように修正します。
memset((char *)s + skip, 0, sizeof(struct big) - skip);
sをchar型へのポインタにキャストすることにより、+skipが
(char型データのバイト数 * skip)
と解釈されます。char型データのバイト数は1であり、元の意図どおりの挙動になるというわけです。
実際にあったポインタ演算の誤り
もうひとつ例を見てみましょう。
これはJava VMのオープンソースな実装であるkaffeにあったコードを基にしています。
char *old_blocks; // 元の基準位置 char *gc_block_base; // 新たな基準位置 gc_block *X; ...... int delta = gc_block_base - old_blocks; ...... if(X) X = X + delta;
ガベージコレクタのコードのなかで、メモリブロックXの位置をdeltaバイトだけずらそうという意図で、X = X + delta;としています。しかし、Xはgc_blockへのポインタなので、これではdeltaバイトずらす代わりに(delta * sizeof(gc_block))バイトずらしてしまっています。これにより、想定外のメモリアドレスへの書き込みを行ってしまい、プログラム中で使っているデータが破壊される危険があります。
このケースも、以下のような修正を行えば、バイト単位の加算が行われるようになります。
if(X) X = (gc_block *)((char *)X + delta);
(char *)Xとキャストすることによりdeltaとの加算がバイト単位で行われ、計算結果を元のポインタ型データとしてXに代入するために右辺全体を(gc_block *)でキャストしています。
ちなみに deltaはふたつのポインタの差分を計算していますが、このようなデータに対してC99では、ptrdiff_tという型が規定されています。コンパイラがこの型をサポートしている場合、プログラマの意図を明確に表現するためにも、deltaをptrdiff_t型と宣言するべきでしょう。
ポインタ演算の勘違いがもたらす脆弱性
配列データを扱ったり動的なメモリ管理を行うようなアプリケーションでは、ポインタを適切に使うことが重要です。ここでとりあげた例では、直接外部からの攻撃に使われる危険は少ないかもしれませんが、外部からの入力値を処理したり、入力に応じて内部データの操作を行う部分にエラーがあった場合、攻撃に使われる危険が高くなります。
そのような問題のあるコードを作らないため、また、後日のメンテナンスで間違った更新を行わないように、ポインタを正しく使ったコードを書くこと、およびプログラマの意図を適切に表現することが重要です。
参考文献
- C言語仕様セクション6.2.5 Types (ポインタ型の定義)
- C言語仕様セクション6.5.6 Additive Operators
- OpenBSD Journal 「Developer blog: cnst@: fixing make」
- CVS kaffe (guilhem) 「Fixed pointer arithmetic in the GC.」