目次へ戻る §25へ戻る §27へ進む

26.マルチスレッドと IsBadXxxPtr の危険な関係

2012.3.8 (鈴)

26.1 はじめに

去年の 3 月から1年近く現行版だった Cygwin 1.7.9 もついに先月 5 日 1.7.10 に更新 [cygwin.com] され,ついで 25 日に 1.7.11 に更新 [cygwin.com] されました。 すぐ目につくような違いは特にありませんが,ほぼ同時期,2008 年 4 月以来,約 4 年ぶりの 2 月 7 日の Tcl/Tk 8.5.11 の更新 [cygwin.com] が個人的には影響大でした。 何か迷った時,PyChing (第 15 章 脚注 11 参照) を使って P. K. ディックの「高い城の男」の登場人物のようにときどき易に問うているのですが,今度の版は Tk が X11 ベースになったため,X サーバを立ち上げておかなくてはならなくなりました。

閑話休題,
国内ではまだあまり知られていませんが,Windows には基本的な設計に由来するある奇妙な制限があります。 それは簡単に言えば,

  マルチスレッドのもとでは未知のポインタの妥当性を例外キャッチを使って調べることは危険である。
  調べた後,いつかプロセスが手がかりを残さず突然死するおそれがある。

というものです。 英語サイトでは 6 年前から公然と説明されています。また,少し置いて提供が開始された Windows Vista 以降で IsBadXxxPtr 関数が禁止された理由の一つです。

本章ではこれを Cygwin を使って検証してみます。 以下,まず Cygwin 上の pthread ライブラリを紹介し,それから上記の問題を解説します。 次に実験方法とその結果を示します。 Windows XP SP3 を使います。

26.2 簡単な pthread の紹介

Cygwin は Unix の標準的なスレッド実装ライブラリである pthread をデフォルトで備えています。 コンパイル時,gcc に付加的なオプションを与える必要はありません。 ヘッダ・ファイル pthread.h も Cygwin 本体である cygwin-1.7.11-1 パッケージによって直々に用意されています。

01:~$ cygcheck -f /usr/include/pthread.h
cygwin-1.7.11-1
01:~$  
今は mintty が Cygwin の標準の端末プログラムですから, 第 24 章 の mintty の設定に準じて端末を白地に黒で表現します。 オレンジ色の四角はカーソルを表しています。

ここでは,新しいスレッドを作る pthread_create と,作ったスレッドの終了を待ち合わせる pthread_join の二つのライブラリ関数を使います。 それぞれの仕様については Cygwin が準拠している Linux の

を参照してください。下記がその使用例です。

#include <stdio.h>
#include <pthread.h>

void *foo(void *arg)
{
    int i;
    fprintf(stderr, "*arg = %d\n", *((int *)arg));
    for (i = 0; i < 5; i++) 
        fprintf(stderr, "%8d\n", i);
    return NULL;
}

int main()
{
    pthread_t th;
    int i_arg = 2012;
    int r = pthread_create(&th, NULL, foo, &i_arg);
    if (r != 0) {
        printf("pthread_create: %d\n", r);
        return 1;
    }
    printf("A\n");
    printf("B\n");
    r = pthread_join(th, NULL);
    if (r != 0) {
        printf("pthread_join: %d\n", r);
        return 1;
    }
    printf("C\n");
    printf("D\n");
    return 0;
}

ここで main 関数はまず,pthread_create で新しいスレッドを生成します。 第1引数 &th の指す先である th に,新スレッドの識別子が格納されます。 スレッド属性を指定する第2引数が NULL ですから,新スレッドはデフォルトの属性を持ちます。 新スレッドは第3引数 foo&i_arg を実引数として実行します。

次に main 関数は文字 AB を表示した後,pthread_join でスレッドの終了を待ちます。 第1引数 th は待ち合わせ対象のスレッドの識別子です。 第2引数が NULL ですから,スレッドの戻り値は格納せずに捨てます。 待ち合わせ後,文字 CD を表示します。

一方,新スレッドは前述のように &i_arg を実引数として関数 foo を実行します。 関数 foo では引数の指す先の整数 (ここでこれは main のローカル変数 i_arg の値 2012 のはずです) を表示した後,0 から 4 まで整数を表示し, NULL を戻り値とします。関数 foo が終わるとき,スレッドも終わります。

下記にコンパイル&実行例を示します。

01:~/tmp$ gcc -Wall p1.c
01:~/tmp$ ./a.exe
*arg = 2012
       0
       1
A
B
       2
       3
       4
C
D
01:~/tmp$  
main を実行する主スレッドと foo を実行する新スレッドは並行して動作しますから, *arg = 2012 と 0 と 1 を新スレッドが表示した後,主スレッドが AB を表示し,また新スレッドが 2, 3, 4 を表示しています。 主スレッドは新スレッドの終了を pthread_join で待った後,CD を表示しています。

出力先についての注意

新スレッドによって実行される関数 foo で標準エラー出力 stderr を出力先としていることに注意してください。 これを標準出力 stdout に変更すると次のようになります。 この実行例では,新スレッドと並行して元のスレッドで出力されているはずの A が表示されていません。

01:~/tmp$ ./a.exe
*arg = 2012
       0
       1
       2
       3
       4
B
C
D
01:~/tmp$  

これは標準出力の出力バッファが二つのスレッドから排他制御なしに使われるためです。 本当はきちんと排他制御すべきですが,とりあえず試すだけならば stdoutstderr に分ければさしあたり十分です。

26.3 IsBadXxxPtr の知られざる問題

背景

数年前から Windows 開発者を騒がせている話題の一つとして Windows Vista と Windows Server 2008 で IsBadReadPtrIsBadWritePtr 関数が禁止されたことが挙げられます。 Windows Vista および Windows Server 2008 アプリケーション互換性解説書 [microsoft.com] には次のように書かれています。

以前のバージョンの Windows では、IsBadReadPtr および IsBadWritePtr 関数を使用してパラメータを検証していました。Windows Vista® および Windows Server® 2008 では、これらの関数は禁止されました。

IsBadReadPtrIsBadWritePtr は,引数として与えられたポインタが妥当であるか (そのポインタの指す先のメモリを安全に読み取れるか, または書き込めるか) をテストする述語関数です。 総称的に IsBadXxxPtr と書かれることもあります。 これがなぜ禁止されたのかを明快に説明した日本語文書は公式・非公式を問わず,執筆時現在,知る限りではどこにもないようです。 例えば日本語版 MSDN の IsBadReadPtr 関数 [microsoft.com] にはこの関数に関する否定的な記述として下記があるだけです。

プリエンプティブなマルチタスキング環境では、テスト中のメモリに対する プロセスのアクセス状況を、他のスレッドが変更する可能性があります。こ の関数を使って、プロセスが指定のメモリへの読み取りアクセスを行えるこ とを確認した場合でも、そのメモリへのアクセスを試みる前に構造化例外処 理を使うべきです。構造化例外処理を使うと、アクセス違反が発生した場合 でもシステムはプロセスにそのことを通知し、プロセスはその例外を処理す る機会が得られます。

これを読む限りでは「この関数」つまり IsBadReadPtr は単に結果が信頼できないから使うべきではなく,代替手段として構造化例外処理を使って処理すればよい,というようにも読めます。 しかし,それだけのことで果たして禁止とまでするのでしょうか?

恐るべき禁止の理由?

一方,英語版 MSDN の IsBadReadPtr function [microsoft.com] には,この関数を禁止するに足る恐るべき記述があります。

Dereferencing potentially invalid pointers can disable stack expansion in other threads. A thread exhausting its stack, when stack expansion has been disabled, results in the immediate termination of the parent process, with no pop-up error window or diagnostic information.

訳せば,

潜在的に非妥当なポインタを間接参照することは,別のスレッドでのスタックの拡張を効かなくするかもしれない。 スタックの拡張が効かなくなっていると,スレッドがスタックを使い尽くしたとき, なんらポップアップ・エラー・ウィンドウも診断情報もなく,親プロセスの突然の停止に終わる。

ただ,たしかに文言は恐ろしいのですが,これだけでは IsBadReadPtr 関数とのつながりがよく見えません。 むしろ,特定の関数によらない Windows の弱点を説明しているように読めます。

この説明が IsBadReadPtr 関数とつながるには,IsBadReadPtr 関数が,渡されたポインタを何らかの範囲チェックでテストしているのではなく, 実際にポインタの間接参照をしてテストしている,と仮定せざるを得ません。 逆にいえば,この記述によって IsBadReadPtr の内部実装が明らかになっているとも言えます。 IsBadWritePtr も同様です。

しかし,まだこれだけでは断言はできません。

この段落がその前後と明示的なつながりなしに書かれているため,これだけでは 「うかつに間接参照すると危ないから,必ず事前に (この関数で) ポインタをテストしましょう」 と真逆に解釈することさえ,無理筋とはいえ不可能ではありません。

その真相

その真相は 2006 年 9 月 27 日付けの MSDN のブログに書かれていました。

IsBadXxxPtr should really be called CrashProgramRandomly [msdn.com] (Raymond Chen)
……
if the "bad pointer" points into a guard page, then probing the memory will raise a guard page exception. The IsBadXxxPtr function will catch the exception and return "not a valid pointer". But guard page exceptions are raised only once. You just blew your one chance.
……
your program does use guard pages; you just don't realize it. The dynamic growth of the stack is performed via guard pages: Just past the last valid page on the stack is a guard page. When the stack grows into the guard page, a guard page exception is raised, which the default exception handler handles by committing a new stack page and setting the next page to be a guard page.

訳せば,

IsBadXxxPtr は本当は CrashProgramRandomly (ランダムにプログラムをクラッシュさせる) と呼ぶべきである。
……
もしも「悪いポインタ」がガード・ページ内を指しているならば,そのメモリを検査するとき,ガード・ページ例外が発生する。 IsBadXxxPtr 関数はこの例外をキャッチし,「妥当なポインタではない」との結果を返す。 しかし,ガード・ページ例外はたった一度しか発生しない。 君はそのたった一度のチャンスを吹き飛ばしてしまった。
……
君のプログラムはガード・ページを使っている。君はただそれに気付いていないだけだ。 スタックの動的な成長はガード・ページによって実施される。 スタックのうち最後に有効になったページのひとつ前がガード・ページだ。 スタックがガード・ページのところまで成長すると,ガード・ページ例外が発生する。 それをデフォルトの例外ハンドラが処理して,新しいスタック・ページをコミットし, その次のページをガード・ページにするというわけだ。

ブログ本文に続く一連の議論では,より詳細な条件が挙げられています。結論を要約します。

  1. スタックの拡張はカーネル内のハンドラが行う。 発生した例外に対し,このハンドラは,ユーザの例外ハンドラ (IsBadXxxPtr ライブラリ関数実装内のハンドラを含む) に横取りされて処理を仕損なうことはない。
  2. しかし,カーネル内のハンドラは,現在のスレッドに対してガード・ページ例外が発生したときだけスタックを拡張する。
  3. つまり,あるスレッドが別のスレッドのガード・ページを間接参照したときはスタックの拡張は起こらない。
  4. このとき発生したガード・ページ例外をユーザの例外ハンドラ (典型的には IsBadXxxPtr ライブラリ関数実装内のハンドラ) でキャッチし,そのまま処理を続行した場合, その別スレッドで本当にスタックがあふれた時,もうガード・ページ例外は発生しない。プロセスがクラッシュする。

これは英語版 MSDN の IsBadReadPtr function の恐るべき記述を説明し,補足するものです。

そして,そうだとすれば,もしこのようにプロセスがクラッシュした場合,その原因を解析することは極めて困難な作業になります。 外から見て概ね最後に活動していたスレッドが,スタックの現在のページを使い尽くしたスレッドであると推測できても, そのスレッド自体は全くの無実です。 マルチスレッドのもとで,妥当性について情報がない未知のポインタを IsBadXxx 関数でテストすること (= 間接参照で発生した例外をキャッチして処理を続行すること) は,Windows プログラミングで必ず避けるべきことの一つだと言っても過言ではありません。 もちろん,構造化例外処理は決して IsBadXxxPtr の安全な代替手段にはなりません。

26.4 実験

この問題があまり知られていないのは,任意に与えられたポインタが, たまたまどれか別のスレッドのスタックの最後のメモリ・ページの次のページの中を指していなければ発現しない, という厳しい条件があったからでしょう。 IsBadXXXPtr を使っても普通はこの突然死というべき現象は見られず,まれに発現したとしても, 手がかりがつかめない謎の現象として扱われ,真相に至ることはほとんどなかったはずです。

この現象を意図的に発生させるには,やみくもにポインタを使うのではなく, スタックのアドレスを取得し,それに適当な変位をつけて間接参照すればよいはずです。

現行の C 言語実装では,ローカル変数に & 演算子を適用してスタック・トップ付近のアドレスを取得できます。 これを新スレッドに引数 arg として渡してやり,適当な変位で *(arg - 変位) の値をとれば,もとのスレッドのスタックのガード・ページに間接参照することができます。

まず手始めに,間接参照だけしてみます。

#include <stdio.h>
#include <pthread.h>

void *foo(void *arg)
{
    char* p = (char *)arg;
    int i;
    for (i = 0; i < 20; i++) {
        int ch = *p;
        fprintf(stderr, "*(%p) = %02x\n", p, ch);
        p -= 0x1000;
    }
    return NULL;
}

int main()
{
    pthread_t th;
    int r = pthread_create(&th, NULL, foo, &th);
    if (r != 0) {
        printf("pthread_create: %d\n", r);
        return 1;
    }
    printf("A\n");
    printf("B\n");
    r = pthread_join(th, NULL);
    if (r != 0) {
        printf("pthread_join: %d\n", r);
        return 1;
    }
    printf("C\n");
    printf("D\n");
    return 0;
}
01:~/tmp$ gcc -Wall p2.c
01:~/tmp$ ./a.exe
*(0x22ccb8) = 38
*(0x22bcb8A
B
) = 00
*(0x22acb8) = 00
*(0x229cb8) = 00
*(0x228cb8) = 00
*(0x227cb8) = 00
*(0x226cb8) = 00
*(0x225cb8) = 00
*(0x224cb8) = 00
*(0x223cb8) = 00
*(0x222cb8) = 00
*(0x221cb8) = 00
*(0x220cb8) = 00
*(0x21fcb8) = 00
*(0x21ecb8) = 00
*(0x21dcb8) = 00
*(0x21ccb8) = 00
Bus error (core dumped)
1381:~/tmp$  

この結果から主スレッドのスタックのガード・ページが 0x21ccb8 番地より上,0x21bcb8 番地付近にあるらしいと分かります。 もしもページ境界がアドレスと 4K バイトごとにそろっていれば 0x21b000 番地から 0x21bfff 番地です。 プログラムはプロンプトによれば状態コード 138 = 128 + 10 で終わっています。 10 は SIGBUS ですから,疑似 Unix 環境である Cygwin からみれば bus error シグナルでプロセスが異常終了しています。 もちろんこれは Windows が発生させた例外を Cygwin がそのように読み替えているわけです。

次に,関数 foo で間接参照するかわりに IsBadReadPtr(p, 1) でテストしてみます。 プログラムの前半を次のように変更します。

#include <stdio.h>
#include <pthread.h>
#include <windows.h>

void *foo(void *arg)
{
    char* p = (char *)arg;
    int i;
    for (i = 0; i < 20; i++) {
        if (IsBadReadPtr(p, 1)) {
            fprintf(stderr, "(%p) is bad\n", p);
            break;
        }
        fprintf(stderr, "(%p) is good\n", p);
        p -= 0x1000;
    }
    return NULL;
}
01:~/tmp$ gcc -Wall p3.c
01:~/tmp$ ./a.exe
(0x22ccb8) is good
(0x22bcb8) is good
(A
B
0x22acb8) is good
(0x229cb8) is good
(0x228cb8) is good
(0x227cb8) is good
(0x226cb8) is good
(0x225cb8) is good
(0x224cb8) is good
(0x223cb8) is good
(0x222cb8) is good
(0x221cb8) is good
(0x220cb8) is good
(0x21fcb8) is good
(0x21ecb8) is good
(0x21dcb8) is good
(0x21ccb8) is good
(0x21bcb8) is bad
C
D
01:~/tmp$  

今回は異常終了はしません。 しかし,前節の議論によれば,新スレッド終了時,すでに主スレッドには IsBadReadPtr 内部の例外キャッチにより, スタックの拡張時に突然死する呪いがかけられているはずです。 そうだとすれば今回はたまたま拡張しないまま寿命を全うしただけにすぎません。

そこで最後に,main 関数を次のように替えてみます。 新しいスレッドで foo 関数を実行し終わった後に,主スレッドで bar 関数を再帰的に呼び出してスタックを駆け上ります。

void bar(int i)
{
    char x[0x1000];
    printf("i = %6d, &i = %p\n", i, &i);
    if (i > 0)
        bar(i - 1);
}

int main()
{
    pthread_t th;
    int r = pthread_create(&th, NULL, foo, &th);
    if (r != 0) {
        printf("pthread_create: %d\n", r);
        return 1;
    }
    r = pthread_join(th, NULL);
    if (r != 0) {
        printf("pthread_join: %d\n", r);
        return 1;
    }
    bar(20);
    return 0;
}

実行結果は次のとおりです。奇妙なことに bar 関数が再帰の底 (i = 0) に至らずに終わっています。

01:~/tmp$ gcc -Wall p4.c
p4.c: In function ‘bar’:
p4.c:22:10: warning: unused variable ‘x’
01:~/tmp$ ./a.exe
(0x22ccb8) is good
(0x22bcb8) is good
(0x22acb8) is good
(0x229cb8) is good
(0x228cb8) is good
(0x227cb8) is good
(0x226cb8) is good
(0x225cb8) is good
(0x224cb8) is good
(0x223cb8) is good
(0x222cb8) is good
(0x221cb8) is good
(0x220cb8) is good
(0x21fcb8) is good
(0x21ecb8) is good
(0x21dcb8) is good
(0x21ccb8) is good
(0x21bcb8) is bad
i =     20, &i = 0x22cca0
i =     19, &i = 0x22bc80
i =     18, &i = 0x22ac60
i =     17, &i = 0x229c40
i =     16, &i = 0x228c20
i =     15, &i = 0x227c00
i =     14, &i = 0x226be0
i =     13, &i = 0x225bc0
i =     12, &i = 0x224ba0
i =     11, &i = 0x223b80
i =     10, &i = 0x222b60
i =      9, &i = 0x221b40
i =      8, &i = 0x220b20
i =      7, &i = 0x21fb00
i =      6, &i = 0x21eae0
i =      5, &i = 0x21dac0
01:~/tmp$  

新スレッドについては前回と同じです。 新スレッドはガード・ページ内の 0x21bcb8 番地を IsBadReadPtr 関数でテストし,主スレッドのスタックにこれ以上拡張できない呪いをかけたはずです。

新スレッドが終わった後,主スレッドが bar 関数の再帰呼び出しでこの呪われたスタックを無邪気に駆け上ります。 しかし,ガード・ページの下端近く実引数の場所 &i が 0x21dac0 番地まできたとき (i = 5) がその足跡を確認できる最後です。 このときローカル変数として確保した配列 x が 0x21cac0 番地あたりまで広がっているはずです。 その次の呼び出し時のローカル変数の確保でガード・ページを踏み越えたのでしょう。 全く突然にプロセスが終了しています。 プロンプトに示されている状態コードは正常終了を表す 0 です。 突然死したという手がかりさえ残していません。

対照実験 1

これが本当に求めていた現象なのか確認するため,再び foo を変更します。 今度はただダミーのループを回すだけです。

void *foo(void *arg)
{
    char* p = (char *)arg;
    int i;
    for (i = 0; i < 20; i++) {
        /* if (IsBadReadPtr(p, 1)) {
            fprintf(stderr, "(%p) is bad\n", p);
            break;
        } */
        fprintf(stderr, "(%p) is good\n", p);
        p -= 0x1000;
    }
    return NULL;
}

無事 i = 0 にたどりつきます。 スタックを 0x218a20 番地の高みにまで昇っています。

01:~/tmp$ gcc -Wall p5.c
p5.c: In function ‘bar’:
p5.c:22:10: warning: unused variable ‘x’
01:~/tmp$ ./a.exe
(0x22ccb8) is good
(0x22bcb8) is good
(0x22acb8) is good
(0x229cb8) is good
(0x228cb8) is good
(0x227cb8) is good
(0x226cb8) is good
(0x225cb8) is good
(0x224cb8) is good
(0x223cb8) is good
(0x222cb8) is good
(0x221cb8) is good
(0x220cb8) is good
(0x21fcb8) is good
(0x21ecb8) is good
(0x21dcb8) is good
(0x21ccb8) is good
(0x21bcb8) is good
(0x21acb8) is good
(0x219cb8) is good
i =     20, &i = 0x22cca0
i =     19, &i = 0x22bc80
i =     18, &i = 0x22ac60
i =     17, &i = 0x229c40
i =     16, &i = 0x228c20
i =     15, &i = 0x227c00
i =     14, &i = 0x226be0
i =     13, &i = 0x225bc0
i =     12, &i = 0x224ba0
i =     11, &i = 0x223b80
i =     10, &i = 0x222b60
i =      9, &i = 0x221b40
i =      8, &i = 0x220b20
i =      7, &i = 0x21fb00
i =      6, &i = 0x21eae0
i =      5, &i = 0x21dac0
i =      4, &i = 0x21caa0
i =      3, &i = 0x21ba80
i =      2, &i = 0x21aa60
i =      1, &i = 0x219a40
i =      0, &i = 0x218a20
01:~/tmp$  

対照実験 2

次にシングルスレッドでの動作を試してみます。 この場合は IsBadReadPtr をしても呪いはかからないはずです。

関数 foo を元に戻し,main から単純な関数呼び出しで呼び出すようにします。

#include <stdio.h>
#include <pthread.h>
#include <windows.h>

void *foo(void *arg)
{
    char* p = (char *)arg;
    int i;
    for (i = 0; i < 20; i++) {
        if (IsBadReadPtr(p, 1)) {
            fprintf(stderr, "(%p) is bad\n", p);
            break;
        }
        fprintf(stderr, "(%p) is good\n", p);
        p -= 0x1000;
    }
    return NULL;
}

void bar(int i)
{
    char x[0x1000];
    printf("i = %6d, &i = %p\n", i, &i);
    if (i > 0)
        bar(i - 1);
}

int main()
{
    pthread_t th;
    foo(&th);
    bar(20);
    return 0;
}

この場合も無事 i = 0 にたどりついています。 スタックを 0x218a20 番地の高みにまで昇っています。

01:~/tmp$ gcc -Wall p6.c
p6.c: In function ‘bar’:
p6.c:22:10: warning: unused variable ‘x’
01:~/tmp$ ./a.exe
(0x22ccbc) is good
(0x22bcbc) is good
(0x22acbc) is good
(0x229cbc) is good
(0x228cbc) is good
(0x227cbc) is good
(0x226cbc) is good
(0x225cbc) is good
(0x224cbc) is good
(0x223cbc) is good
(0x222cbc) is good
(0x221cbc) is good
(0x220cbc) is good
(0x21fcbc) is good
(0x21ecbc) is good
(0x21dcbc) is good
(0x21ccbc) is good
(0x21bcbc) is good
(0x21acbc) is good
(0x219cbc) is good
i =     20, &i = 0x22cca0
i =     19, &i = 0x22bc80
i =     18, &i = 0x22ac60
i =     17, &i = 0x229c40
i =     16, &i = 0x228c20
i =     15, &i = 0x227c00
i =     14, &i = 0x226be0
i =     13, &i = 0x225bc0
i =     12, &i = 0x224ba0
i =     11, &i = 0x223b80
i =     10, &i = 0x222b60
i =      9, &i = 0x221b40
i =      8, &i = 0x220b20
i =      7, &i = 0x21fb00
i =      6, &i = 0x21eae0
i =      5, &i = 0x21dac0
i =      4, &i = 0x21caa0
i =      3, &i = 0x21ba80
i =      2, &i = 0x21aa60
i =      1, &i = 0x219a40
i =      0, &i = 0x218a20
01:~/tmp$  

今回は is bad と判定されていませんが, これは同じスレッドからの (IsBadReadPtr 関数内部での) 間接参照であることから, カーネル内の例外ハンドラによって最初にスタックの拡張が行われ,そこで例外処理が完結したためと考えられます。

この推理を実験的に確かめてみます。 ガード・ページを跳び越すようにアクセスすれば,スタック拡張を行わせずに is bad と判定できるはずです。 さらに,そう判定したとき間接参照したのは,まだガード・ページになっていないページですから,その後のスタック拡張も問題ないはずです。

01:~/tmp$ diff p6.c p7.c
15c15
<         p -= 0x1000;
---
>         p -= 0x2000;
11:~/tmp$ gcc -Wall p7.c
p7.c: In function ‘bar’:
p7.c:22:10: warning: unused variable ‘x’
01:~/tmp$ ./a.exe
(0x22ccbc) is good
(0x22acbc) is good
(0x228cbc) is good
(0x226cbc) is good
(0x224cbc) is good
(0x222cbc) is good
(0x220cbc) is good
(0x21ecbc) is good
(0x21ccbc) is good
(0x21acbc) is bad
i =     20, &i = 0x22cca0
i =     19, &i = 0x22bc80
i =     18, &i = 0x22ac60
i =     17, &i = 0x229c40
i =     16, &i = 0x228c20
i =     15, &i = 0x227c00
i =     14, &i = 0x226be0
i =     13, &i = 0x225bc0
i =     12, &i = 0x224ba0
i =     11, &i = 0x223b80
i =     10, &i = 0x222b60
i =      9, &i = 0x221b40
i =      8, &i = 0x220b20
i =      7, &i = 0x21fb00
i =      6, &i = 0x21eae0
i =      5, &i = 0x21dac0
i =      4, &i = 0x21caa0
i =      3, &i = 0x21ba80
i =      2, &i = 0x21aa60
i =      1, &i = 0x219a40
i =      0, &i = 0x218a20
01:~/tmp$  

この結果は推理を裏付けています。

対照実験 3

最後に再びマルチスレッドで動作させてみます。 ただし,今度は IsBadReadPtr 関数を使いません。独自に例外処理をしてみます。 本節最初の実験が示すように Cygwin では発生した例外を Unix の bus error シグナルに読み替えますから,SIGBUS をハンドルすることで処理を実現します。

#include <stdio.h>
#include <pthread.h>
#include <signal.h>
#include <stdbool.h>

volatile bool signaled = false;

void handler(int sig)
{
    signaled = true;
}

void *foo(void *arg)
{
    char* p = (char *)arg;
    int i;
    for (i = 0; i < 20; i++) {
        int ch = *p;
        if (signaled) {
            fprintf(stderr, "(%p) is bad\n", p);
            break;
        }
        fprintf(stderr, "*(%p) = %d\n", p, ch);
        p -= 0x1000;
    }
    return NULL;
}

void bar(int i)
{
    char x[0x1000];
    printf("i = %6d, &i = %p\n", i, &i);
    if (i > 0)
        bar(i - 1);
}

int main()
{
    pthread_t th;
    sigset(SIGBUS, handler);
    int r = pthread_create(&th, NULL, foo, &th);
    if (r != 0) {
        printf("pthread_create: %d\n", r);
        return 1;
    }
    r = pthread_join(th, NULL);
    if (r != 0) {
        printf("pthread_join: %d\n", r);
        return 1;
    }
    bar(20);
    return 0;
}

実行結果は次のとおりです。IsBadReadPtr 関数を使った場合と同じようにプロセスが突然死しています。

01:~/tmp$ gcc -Wall p8.c
p8.c: In function ‘bar’:
p8.c:31:10: warning: unused variable ‘x’
01:~/tmp$ ./a.exe
*(0x22ccb8) = 56
*(0x22bcb8) = 0
*(0x22acb8) = 0
*(0x229cb8) = 0
*(0x228cb8) = 0
*(0x227cb8) = 0
*(0x226cb8) = 0
*(0x225cb8) = 0
*(0x224cb8) = 0
*(0x223cb8) = 0
*(0x222cb8) = 0
*(0x221cb8) = 0
*(0x220cb8) = 0
*(0x21fcb8) = 0
*(0x21ecb8) = 0
*(0x21dcb8) = 0
*(0x21ccb8) = 0
(0x21bcb8) is bad
i =     20, &i = 0x22cca0
i =     19, &i = 0x22bc80
i =     18, &i = 0x22ac60
i =     17, &i = 0x229c40
i =     16, &i = 0x228c20
i =     15, &i = 0x227c00
i =     14, &i = 0x226be0
i =     13, &i = 0x225bc0
i =     12, &i = 0x224ba0
i =     11, &i = 0x223b80
i =     10, &i = 0x222b60
i =      9, &i = 0x221b40
i =      8, &i = 0x220b20
i =      7, &i = 0x21fb00
i =      6, &i = 0x21eae0
i =      5, &i = 0x21dac0
01:~/tmp$  

IsBadReadPtr 関数を使うことがいけないのではなく, IsBadReadPtr 関数のように Windows の例外をキャッチして処理を続行することがいけないのです。

26.5 おわりに

本章では Cygwin の pthread を紹介し,それを使ってマルチスレッド環境での IsBadXxxPtr 関数の危険性を実験的に明らかにしました。 現行の Windows 実装にこの危険から逃げる現実的な方法はおそらくありません。 もしあればそれを使って IsBadXxxPtr 関数の内部を安全なものに作り替えていたはずです。

しかし,だからといって,必ずしも致命的な弱点というわけではありません。 今回の実験にそのヒントがあります。

プログラムでは,NULL かどうかなど明らかに安全であるテスト以外では,ポインタの妥当性をチェックしないようにします。 不正な間接参照で例外が発生しても,そのままプロセスが異常終了するに任せます。 こうしたとき,突然死の場合と異なり,他のプロセスからそれが異常終了であると認識できます (Cygwin では状態コードからシグナルによる終了と識別できます) から,善後策をとることができます。 これは決して不正なポインタにまつわる問題をすべて解決する方法ではありませんが,IsBadXxxPtr 関数で達成するはずだった頑健性を,マルチスレッドでは無理でもマルチプロセスのシステムによって達成することはできるわけです。


Copyright (c) 2012 OKI Software Co., Ltd.