第10章 著名な脆弱性対策
整数オーバーフロー攻撃対策

整数オーバーフロー

C言語やC++においては、整数演算がオーバーフローを起こしたり、ビット幅の少ない変数への代入によって上位ビットが失われても処理系がそれを誤りとして検出しないことが多い。このことが、整数オーバーフロー問題を起こりやすくしている。

整数オーバーフロー攻撃

整数オーバーフロー攻撃は、二進数の整数演算結果が予定外の値を生むケースを悪用し、侵入コードの送り込みと実行をもくろむものである。

この攻撃が成立すると、任意のマシンコードが実行され、最悪の場合攻撃者にコンピュータの管理者権限を取得される。軽くても、標的プログラムがサービス不能状態に陥ることが考えられる。

攻撃のメカニズムはつぎのようなものである──攻撃者はデータ配置のオフセットや転記データ長として、予定外の符号の値あるいは範囲を超えた値を生じさせるよう整数演算を誘導し、それを利用してメモリ上に攻撃コードを送り込む。それとともに関数の戻りアド レスやプログラムが使っている何らかのジャンプ先アドレスを書き換えることで、攻撃コードへ制御を移す。

(1) 侵害例・管理情報つき文字列

例えば、次のような管理情報つきの文字列データ構造が大きなマルチメディアデータファイルに含まれているような場合を考える。

#define MAXBODY 496

// 構造体 (500バイト)
typedef struct bravo_s {
   short width;         // 1文字あたりバイト数: 1, 2 または 4.
   short count;         // 文字数.
   char  body[MAXBODY]; // 文字列.
} Bravo;

この文字列データ構造はひとつの文字を1、2、4バイトのいずれでも表現でき、管理情報も 含めて500バイトに収容するというものである。管理情報としては、ひとつの文字を表のに 用いるバイト数(1, 2, 4)と、収容している文字数のふたつをそれぞれshort型のフィール ドでもつ。

(2) 転記関数

この管理情報付き文字列データ構造を転記する関数を次のように作ってみた。

// 転記する関数
int br_copy(Bravo* a, Bravo* b) {
   short bytes = b->width * b->count;
   if (bytes <= MAXBODY) { // 転記するバイト数をチェック
      a->width = b->width;
      a->count = b->count;
      memcpy(a->body, b->body, b->width * b->count);
      return 0;
   } else {
      printf("***ERROR***  br_copy failed.  bytes=%d\n", bytes);
      return -1;
   }
}

この転記関数を使う場面は例えば次のようになる。

// 転記の場面
void do_something(Alpha* multimedia_data) {
   Bravo *p1 = multimedia_data->bravo;
   Bravo b2;

   // 管理情報つき文字列の、ローカル変数への転記
   int err = br_copy(&b2, p1); 

   ... ローカル変数 b2 を使った処理 ...

}

(3) 攻撃例

上記の転記関数 br_copy() は、実は整数オーバーフロー攻撃に対して脆弱である。攻撃者は、br_copy() が転記する転記元のデータとして次のような内容を用意する。

width ← 4
count ← 9000
body  ← バッファオーバーフロー攻撃データ

このようなデータが与えられると、br_copy() のサイズチェックロジックがはたらかなくなる。変数 bytes への代入結果が -29536 という値になるため、MAXBODY を使った上限検査が迂回されてしまい、36000 バイトの転記が起こる。br_copy() を呼び出す関数内でバッファオーバーフローが起こり、関数からのリターン時に制御を奪われるおそれがある。

整数オーバーフロー攻撃
図10-10: 整数オーバーフロー攻撃

C/C++言語の整数型

C/C++言語における整数型は次のようになっている。

(1) 整数型のバリエーション

C/C++言語には整数データ型として多くのバリエーションがある。古典的、あるいは仕様がプラットフォームに依存する整数型として次のものがある。

char, unsigned char
short, unsigned short
int, unsigned int
long, unsigned long
long long, unsigned long long

char と unsigned char はいずれも文字のためのデータ型であるが、C/C++言語においてはいずれも8ビット幅の二進数として扱われる。

※型名に関する補足

整数の型の省略形と本来の型名であるフルスペルは、以下の表の通りである。これらの中の signed と int は省略されることが多い。

省略形 フルスペル(本来の型名)
short signed short int
long signed long int
long long signed long long int
unsigned short unsigned short int
unsigned long unsigned long int
unsigned long long unsigned long long int

char 型はプラットフォームやコンパイルオプションによって符号なし (unsigned char) として扱われる場合がある。そのような環境において符号つきの型であることを明示的に示す必要のある場合は signed char のように書く。

(2) 同じ型名でもビット幅は一定でない

上記の型名の整数型が何ビットのビット幅をもつものであるかは、処理系やコンパイラのオプションによって変わり得る。かつての16ビットプロセッサの処理系では、int型==short型==16ビット二進数 だった。現在多く使われている32ビットプロセッサの処理系では、int型==long型==32ビット二進数 となっている場合が多い。

ソフトウェアによっては int型 の変数のビット幅がコンパイラによって変わってしまうことは大きな問題となる。多くの開発プロジェクトでは、例えば次のようなカスタム型定義を処理系別に用意し、C言語に備わっている型名を直接使用しないようにすることも少なくない。

"portable_types.h"
typedef signed char    CHAR;
typedef unsigned char  BYTE;
typedef short          INT16;
typedef unsigned short UINT16;
typedef long           INT32;
typedef unsigned long  UINT32;

(3) ビット幅を明示した整数型

C99 においては8、16、32、64ビットのそれぞれのビット幅を明示する形の整数型が導入された。それらは次のようなものである。

使用するインクルードファイル
   #include <stdint.h>

定義されている整数型
   int8_t, uint8_t
   int16_t, uint16_t
   int32_t, uint32_t
   int64_t, uint64_t

整数の落とし穴

C/C++言語における整数の取り扱いには、いくつも注意を要する事項がある。主として次のようなものである。

(1) 暗黙のビット幅拡張

int よりもビット幅の小さな型(char, unsigned char, short, unsigned short)の値は int に拡張されてから演算が行われる。

int よりもビット幅の小さな型(char, unsigned char, short, unsigned short)の値はint に拡張されてから引数として関数に受け渡される。

例 次のコードを実行すると fffffffc が表示される。
signed char ch = 0xfc;
printf("%x", ch);

(2) 代入時ノーチェック

異なる整数型間の代入がコンパイル時に警告されない。また実行時にも、少ないビット幅の型の変数への代入で上位のビットが失われたときもエラーとして検出されない。

int i = 0x12345000;
short s = i;  /* s は 20480 になる */

符号付き整数と符号なし整数の間の代入も警告されない。

short s = -30000;
unsigned short u = s; /* u は 35536 になる */

(3) 演算オーバーフローの無視

演算結果(の絶対値)が大きくなり過ぎてビット幅に入りきらないときにもエラーとして検出されない。

int a = 65536;
int b = 32768;
int c = a * b;  /* c は負の数 -2147483648 になる */

(整数オーバーフロー問題対策

整数オーバーフロー問題への対策には、次のものがある。

(1) できる限り符号なし整数を使う

真に負の値の演算を必要とする場面は必ずしも多くない。サイズ、個数、オフセット、添字等の値はほとんどが非負の値でありこれらはすべて符号なし整数型を用いるべきである。

また、通常の個数等に混在させて、「データが見つからない」「エラーが生じた」等の例外的な状況を -1, -2, -3 といった負の値で表す場合があるが、この方式はなるべく避ける。例外的状況は別の方法で報告すべきである。

残念ながら、C言語のライブラリ関数には、0 以上の値で正常値を返し、-1 でエラーを報告する仕様のものが多い。しかしながら、この方式は見習うべきでない。

(2) できる限りビット幅のバリエーションを減らす

int よりも狭いビット幅の型の多用を避ける。データの保存時に上位ビットが欠落するおそれがあるためである。

もしメモリ容量やファイル容量に余裕があるならば、使用する変数はなるべくビット幅の大きな整数型で統一する。

(3) 事前検査、事後検査

整数の処理をプログラミングするときには、演算結果がオーバーフローする可能性、代入によるビット落ちが起こる可能性を念頭に置く。想定される事象にもとづき、処理を行う直前(事前条件)、および処理結果を所定の領域に格納した時点(事後条件)でそれぞれの変数が適切な値を保持していることを確認する検査ロジックをはたらかせる。

(4) アサーションによる検査の記述

範囲外の値への対応をアプリケーションロジックとしてきめ細かくプログラミングすることが大きな負担となり実現しづらい場合でも、事前条件の検査、事後条件の検査をアサーションとして記述し、実行時にもそのアサーションが機能するようにしておく。

#include <assert.h>
…
assert ((a >= a_min && a <= a_max) && (b >= b_min && b <= b_max));
c = a + b;
assert ((b > 0 && c > a) || (b < 0 && c < a) || (b == 0 && c == a));

(5) 整数演算C++クラスの利用

上記以外にも、C++言語であれば、算術演算子 + - * / 等をクラスのメンバ関数として用いることができるので、「安全な整数演算」のクラスを設けてそれを使うといった対策が考えられる。

また、C言語においても算術演算をオーバフローチェックを行う演算関数に置き換えて記述する──ソースコードの読みやすさは損なわれるが──方法がある。

(6) ツールの利用

一部のコンパイラでは、整数オーバーフロー問題の発生を実行時に検出するマシンコードを生成することができる。ただし、カバーできる問題の範囲はかなり限定されている。

・GCC の -ftrapv

gcc および g++ には -ftrapv という、符号付き整数のオーバーフローを検出してSIGABRT(Abortシグナル)を発生させるようになるオプションがある。ただし、このオプションを使用すると整数演算がライブラリ関数で実行されるようコンパイルされ、処理速度が低下する。また、32ビットおよび64ビットの符号付き整数同士の加減算、乗算のみがオーバーフローのチェックの対象である。

・Visual C++ の /RTCc

Visual C++ には /RTCc というコンパイラオプションがあり、ビット幅の少ない変数への代入により上位ビットが失われたとき実行時エラーとして検出するようにする機能がある。ただし、演算結果のオーバーフローを検知するためのオプションは今のところ装備されていないない。

まとめ

C/C++言語には、整数型の多くのバリエーションが存在するが、その一方で演算オーバーフロー、代入時の上位ビット消失、符号あり型符号なし型間で値が保存されない等の問題が実行時エラーとして検出されない。このことが、整数オーバーフロー問題を生み、整数オーバーフロー攻撃の余地を作っている。

整数オーバーフロー問題への対策には、取り扱う整数の型をなるべく統一し大きなビット幅かつ符号なしにする、演算結果を検証する、整数演算のC++クラスを導入する、コンパイラのオプションを利用する等がある。