第8章 セキュアWindowsプログラミング
[8-1.]
Windowsパス名の落とし穴
Windowsの「パス名」は一筋縄ではいかない「くせ」を持っており,ユーザに入力させたパス名をプログラム中で使用するときは注意が必要である。ディレクトリ区切り文字に「\」と「/」の両方が混在・重複しても許されたり,ロングネームとショートネームが存在するなど,複雑な事情がある。



 PDF
● ● ●
パス名
コンピュータに保存されているファイルの識別に用いられる名称が「パス名」である。パス名は,ドライブ文字,ディレクトリパス,ファイル名などを一定の記法に従って連結して表記したものである。Windowsで用いられるパス名は,例えば次のようなものだ。
    d:\InetPub\wwwroot\default.htm
プログラム中にハード・コーディングされる場合もあるが,プログラムがアクセスするファイルのパス名は多くの場合パラメタとしてプログラムの外部から与えられる。システムの重要なファイルが読み出されたり壊されたりしてはならないので,ユーザがプログラムのパラメタに指定できるパス名にはプログラムのロジックで一定の制限をかけることも少なくない。たとえば,あるディレクトリ階層以下のファイルにアクセスを限定する,などである。
 
Windowsのパス名には少々複雑な事情があり,このようなパス名に関する制限のロジックを組むにあたってさまざまな注意が必要である。
文字定数の落とし穴
一つのファイルをオープンする処理は標準のCランタイム関数fopen( )を使って次のように書くことができる。
    FILE* pfile;
    pfile = fopen ("d:\InetPub\wwwroot\default.htm", "r");
残念ながらここには間違いがある。すでに注意深い読者はお分かりだろう,「\」が1個ずつ足りないのだ。CやC++の文字列定数の中ではタブ「\t」,改行「\n」やナルキャラクタ「\0」という記法を許しているため,文字「\」は何らかの特別な意味を持ってしまう。文字「\」そのものを文字列定数の中に指定したいときは,「\」を2つ並べて「\\」のように書く必要がある。従って上のコードは正しくは次のように書かなくてはならない。
    FILE* pfile;
    pfile = fopen ("c:\\InetPub\\wwwroot\\default.htm", "r");
パス名解釈のサービス機能
C言語を使用する場合,Windowsのプログラムがファイルをオープンするのに次の4種類の方法がある。4種類のそれぞれごとに入出力を行う一群の関数が用意されていて,それらの使い方は少しずつ異なっている。
    CreateFile( )
    OpenFile( ) / _lcreat( )
    _open( ) / _creat( )
    fopen ( )
ファイルのオープンにこれら4種類のうちのどれを使っても,仕組みとしては最終的にWin32 APIの関数であるCreateFile( )が呼び出され,そこでパス名の解釈が行われる。他のプログラミング言語を使用する場合もやはり内部でCreateFile( )関数が呼び出されて,そこでパス名の解釈が行われる。
 
このCreateFile( )で行われるパス名の解釈にはいくつものサービス機能があり,パス名の取り扱いを難しくしている。
CreateFile( )関数
本稿では話をパス名に集中するため主にfopen( )関数を用いて説明するが,きめ細かなファイルの取り扱いを指定したい場合は,直接Win32 APIの関数CreateFile( )を用いてファイルを取り扱うことになるだろう。この関数は7つの引数をとり,次のように指定する。
    HANDLE hfile;
    hfile = CreateFile (パス名,読み書きの種別,共有の指定,セキュリティ属性,
     ファイルの生成の要求,ファイルの属性,テンプレートの指定);
上記のd:\InetPub\wwwroot\default.htmを入力でオープンする指定をCreateFile( )関数を用いて書くと,たとえば次のようになる。
    HANDLE hfile;
    hfile = CreateFile ("d:\\InetPub\\wwwroot\\default.htm", GENERIC_READ, 0, NULL,
     OPEN_EXISTING, 0, NULL);
この関数の名前はCreateFileだが,Win32 APIでは既存のファイルをファイルをオープンするだけのときもこの関数を使う。ここで「Create」という語が使われているのは,ファイルアクセスを仲介してくれるWindows NTカーネルオブジェクトを「作る」操作であることに由来していると考えられる。
ディレクトリ階層の落とし穴
ユーザがファイルのパス名,あるいはパス名の一部をパラメタとして入力するとそれに応じたファイルアクセスと処理を行うようなプログラムを考えてみる。こうしたプログラムでは,システムのファイルに干渉されたり秘密のデータファイルが読み出されないよう,ユーザが指定可能なパス名に制限をかけることになる。
 
この制限はユーザが入力してきたパス名を特定の文字列やパターンと照合することによって処理を許可したり禁止したりする形で実装することになるだろう。このときに注意が必要なのは,Windows NT/2000のファイルのパス名にはとても大きな自由度があるということだ。
 
特定のディレクトリパスへのアクセスを制限するつもりで,
    if (_strnicmp (pszPathname, "c:\\InetPub\\wwwroot\\secret\\", 26) == 0) {
     // アクセス禁止
    else
     // アクセス許可
といったロジックを書けば,はたして secret ディレクトリ下のファイルへのアクセスを制限できるだろうか。答えは「ノー」である。(ここで用いている_strnicmp( )は,半角英字の大文字小文字の区別を行わずに指定バイト数だけ文字列を比較する関数である。)
 
次のパス名はすべて同じファイルを示すものとして解釈される:
  1. d:\InetPub\wwwroot\secret\data.txt
     基本形
  2. d:\inetpub\WWWROOT\SECRET\DATA.TXT
     大文字小文字は同一視される
  3. d:/InetPub/wwwroot/secret/data.txt
     ディレクトリの区切りに「/」も使える
  4. d:\\InetPub\\\wwwroot\\\\secret\\\\\data.txt
     ディレクトリの区切り文字は幾つか重複しても構わない
  5. d:////InetPub///wwwroot//secret/data.txt
     「/」も重複できる
  6. d:\InetPub\.\wwwroot\.\.\secret\.\.\.\data.txt
     「カレントディレクトリ」を表す . を差し挟むことができる
  7. d:\fake\fake\..\..\InetPub\wwwroot\secret\data.txt
     実在しないディレクトリもあとで「..」で遡れば指定可能
したがって,上記の1)2)以外のパス名はすべてチェックをすり抜けてしまう。
末尾文字の落とし穴
複数種類のファイルを扱う場面では,ファイルの拡張子に応じて処理を切り替えるといったことが行われる。
 
例えばファイルの内容を開示するプログラムの中の次のような分岐を考えてみる。
    pszPathname = ユーザが入力したパス名
    pszExtension = pszPathnameの中の最後の「.」以降の文字列
    if (_stricmp (pszExtention, ".scr") == 0) {
     // ファイルをスクリプトとして解釈,実行
      ...
    else if (_istrcmp (pszExtention, ".pri") == 0) {
     // プライベートデータのファイル内容は開示しない
      ...
    else {
     // ファイル内容を開示
     pFile = fopen (pszPathname, "r");
      ...
    }
(ここで用いている_stricmp( )は,半角英字の大文字小文字の区別を行わずにナル文字の手前までの内容で文字列を比較する関数である。)
 
ここに,d:\dir\file.priというプライベートデータファイルがあったとすると,このパス名を指定されても拡張子「.pri」をもつファイルはプライベートデータであるとして開示をしないで済む。しかしWin32のパス名には,末尾に付けられた「.」と半角スペースは何個有っても無視される,というサービス機能があるのである。したがって,上のロジックに対し次のようなパス名を与えるとこのファイルの内容が漏洩してしまうことになる。
    d:\dir\file.pri...
すでに述べたとおり,この問題は「.」だけでなく半角スペースが末尾についているときにも起こる。入力欄からデータを取り込むときには前後の空白の除去が欠かせない。
サービス機能の不活性化
多くの自由度を持つWin32のパス名を相手にするには複雑なロジックのプログラムが必要になるが,パス名の解釈に伴う「サービス機能」の多くを不活性化する特殊な指定方法がある。それはパス名の先頭に\\?\という4文字を付け加えることである。たとえば,
    pfile = fopen ("d:\\dir\\data.txt", "r");
の代わりに
    pfile = fopen ("\\\\?\\d:\\dir\\data.txt", "r");
のようにするのである。
 
\\?\から始まるパス名についてはサービス機能が次のようにはたらかなくなる。
  1. ディレクトリ区切り文字「/」が使用できない。
  2. ディレクトリ区切り文字の重複「\\」(C/C++の文字列定数内では"\\\\")が許されない。
  3. パス名の途中に \.\ を差し挟めない。
  4. パス名の途中に \..\ を差し挟めない。
  5. パス名の末尾の「.」や半角スペースが無視されない。
ショートネーム
Windowsのファイルやディレクトリはその名前としてロングネームとそのロングネームから自動生成されたショートネームの2つを持っており,そのどちらを使ってもファイルにアクセスできる。
 
たとえば,
    MyHomePage.html
のように8.3形式におさまらない名前のファイルには
    MYHOME~1.HTM
のような短い別名がシステムにより自動で付けられている。
 
ショートネームの自動生成はかつて8.3形式のファイル名しか扱えなかったWindowsのファイルシステムが改訂されてロングネームが導入されたとき,以前の古いソフトウェアの互換性を保つために導入された機能である。
 
われわれが普段付けるファイル名はショートネームの枠に収まらないことが多いから,たいていはロングネームを使っていると言っていい。そして,ファイルにロングネームで名前を付けると,そのファイルには必ずショートネームも付いている。
 
自動生成されるショートネームは,
    6文字の英数字 「~」 数字  「.」 3文字以内の拡張子
という一般形をもっている。使われる英字はすべて大文字である
ショートネーム機能のスイッチ
Windows NT/2000では,次のレジストリ項目を設定しシステムを再起動することによって,その時点以降ショートネームを自動生成しないようにすることができる。ただし,それ以前に自動生成されたショートネームはそのまま存続する。
    HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\FileSystem\
    NtfsDisable8dot3NameCreation = 1 [REG_DWORD]
せっかくの機能であるが,多くの場合この方法でショートネームの問題を回避しようとすることには無理があるかもしれない。マシン管理者であれば別だが,パッケージソフトウェアを供給するといった立場ではユーザのシステム設定にまで関与できないことが多いからである。
ショートネームの排除
ユーザがある特定のファイルのパス名を入力してきたらそれを拒絶するロジックを書いたとしても,もしロングネームしか考慮していなければ,ショートネームが使われるとチェックを迂回されてしまう。
 
Windows 2000およびWindows 98以降のOSに限定されるが,Win32 APIにはGetLongPathNameという関数があり,与えたパス名をすべてロングネームを使ったパス名に変換して返してくれる。
    GetLongPathName (何らかのパス名, 結果のパス名の領域, 領域長)
    戻り値: 結果のパス名のバイト数
この関数が使える環境であるなら,一旦ロングネームによるパス名に変換してからそのパス名の妥当性を検査することができる。
 
Winodws NT(4.0およびそれ以前)やWindows 95ではこの関数は利用できない。ロングネームのファイル名およびディレクトリ名には文字「~」を使わない,という運用ルールを設定し,ユーザ入力のパス名に「~」が含まれていたらショートネームが混入されていると見なす,といった工夫が必要である。
 
なお,「~」の文字コードの値はJIS(およびASCII)では0x7Eであり,このバイト値はシフトJIS漢字の2バイト目に入り得る。パス名の区切り「\」(0x5C)を扱うときと同様の考慮が必要である。
ストリーム
c: d:などのドライブキャラクタの記述部分を別として,Windows NT/2000のNTFSのパス名では文字コロン「:」の取り扱いにも注意を要する。ファイル名中の「:」はNTFSファイルの「ストリーム」を表す区切り記号として意味を持つからだ。「:」がファイル名に含まれていると,アプリケーションプログラムから見てコンピュータは一見正常に入出力を行うが,それは通常期待されるものとは異なる動作になるのである。
 
ストリームについての説明はNTFSに関する記事に譲ることにする。要点は,ファイル名に「:」が含まれていると具合が悪い,ということである。
予約デバイス名
Windowsには予約デバイス名というものがある。次の名前はディレクトリやファイルの名前には使ってはならないとされているのだ。
    AUX
    CON
    NUL
    PRN
    CLOCK$
    COM1〜COM9
    LPT1〜LPT9
これらは,MS-DOSの時代に用いられていた,コンソール,シリアル通信ポート,プリンタポートなどを表す古典的な名称だが,Windowsにおいてもこれらは有効なのである。
 
特に問題になるのは,c:\dir\aux.txt のようにドライブキャラクタやディレクトリ修飾され,拡張子がついたパス名の中で用いても,ファイル名としては有効でないことだ。とくにCONはこうした文脈でもデバイス名として「正常に」はたらいてしまう。ファイルの内容を読み出すプログラムにファイル名としてCONを含むパス名が与えられると,プログラムはコンソールデバイスを正常にオープンし入力を待ち続け,先に進まなくなる。悪意あるユーザが容易にプログラムを妨害できてしまうことになる。
10番以降のデバイス
通常の指定の仕方では,COM10やLPT10はデバイスではなくファイル名として解釈される。10番以降をデバイスとして扱うには先頭に\\.\を付けて\\.\COM10,\\.\LPT10のように指定する。もちろん,C/C++の文字列定数では次のように\を2個ずつ並べて書くのは言うまでもない。
    pfile = fopen ("\\\\.\\COM10", "w");
予約デバイス名の回避
残念ながらファイルのパス名が予約デバイス名と衝突することを簡単に回避する手段はWin32 APIには用意されていない。対策としては,たとえばリスト1のような照合用の関数を用意して,ユーザが入力してきたパス名に予約名が含まれていないかどうか次のような形で確認しつつ使用する必要がある。
    if (PathContainsReservedName (pszPathname))
     // エラー処理
    else
     // pszPathname をパス名として使用
リスト1 パス名に予約デバイスが名が使われているかどうかを調べる関数
  1 #include <windows.h>
  2 #include <stdio.h>
  3
  4 // 使用するサブルーチン(関数)の宣言。
  5 BOOL TestName (char* pszName, int size);
  6
  7 //
  8 // PathContainsReservedName - パス名が予約デバイス名を含んでいるかどうかの判定。
  9 //
 10 BOOL PathContainsReservedName (char* pszPathname) {
 11   BOOL bResult = FALSE;  // 判定結果。予約デバイス名が含まれているとTRUE。
 12                          // すなわちファイルの識別名として使用できない。
 13   BOOL bShiftJis= FALSE; // シフトJIS漢字コードの2バイト目であるか否か。
 14   int  offsetName= 0;    // ディレクトリ名/ファイル名の開始位置。
 15   int  offsetStream = 0; // ファイル名の最初の「:」の位置。
 16   int  i = 0;            // カウンタ。
 17
 18   // 図式
 19   //
 20   // d:\dir1\dir2\dir3\con.foo.bar:stream
 21   //   || -> | -> | -> |          |
 22   //                   |          |
 23   //            offsetName     offsetStream
 24
 25   // ドライブ文字が先頭に指定されているときはそれを飛び越す。
 26   if (isalpha (pszPathname[0]) && pszPathname[1] == ':') {
 27     i = 2;
 28     offsetName = i;
 29   }
 30
 31   // ディレクトリ区切り文字「\」および「/」ごとに名前の検査を行なう。
 32   for (; pszPathname[i] != '\0'; i++) {
 33     if (bShiftJis) {
 34       // シフトJIS漢字2バイト目のときは何もしない。
 35       bShiftJis = FALSE;
 36     } else if (pszPathname[i] >= '\x81' && pszPathname[i] <= '\x9F' ||
 37         pszPathname[i] >= '\xE0' && pszPathname[i] <= '\xFC') {
 38       // シフトJIS漢字1バイト目なら次は2バイト目
 39       bShiftJis = TRUE;
 40     } else if (pszPathname[i] == '\\' || pszPathname[i] == '/') {
 41       // ディレクトリ区切り文字。
 42
 43       // その直前の名前を検査する。もし予約デバイス名が含まれていたら結論が出た。
 44       bResult = TestName (pszPathname + offsetName, i - offsetName);
 45       if (bResult)
 46         returnbResult;
 47
 48       // 次の階層の名前の先頭位置を記憶しておく。
 49       offsetName = i + 1;
 50     }
 51   }
 52
 53   // ファイル名に続く最初の「:」の位置を求める。「:」の後ろはストリーム名。
 54   // 「:」が含まれていないときは文字列の最後までファイル名。
 55   offsetStream = strlen (pszPathname);
 56   for (i = offsetName; pszPathname[i] != '\0'; i++) {
 57     if (pszPathname[i] == ':') {
 58       offsetStream = i;
 59     }
 60   }
 61
 62   // ファイル名を検査する。(予約デバイス名との照合)
 63   bResult = TestName (pszPathname + offsetName, offsetStream - offsetName);
 64
 65   // 照合結果を返す。
 66   returnbResult;
 67 }
 68
 69 //
 70 // TestName - 名前(パス名ではない)の中に予約デバイス名が含まれているかどうかの照合。
 71 //
 72 BOOL TestName (char* pszName, int size) {
 73   BOOL bResult = FALSE;   // 照合結果。予約デバイス名が含まれているとTRUE。
 74   int  offsetDot = size;  // 名前の中の最初の「.」の位置。
 75   int  i = 0;             // カウンタ。
 76
 77   // 名前の中の最初の「.」の位置を求める。「.」の手前までが予約デバイス名との照合対象。
 78   // 「.」が含まれていないときは名前全体が照合対象。
 79   for (i = 0; i < size; i++) {
 80     if (pszName[i] == '.') {
 81       offsetDot = i;
 82       break;
 83     }
 84   }
 85
 86   // 予約デバイス名との照合
 87   if (offsetDot == 3) {
 88     if (_strnicmp (pszName, "AUX", 3) == 0 ||
 89         _strnicmp (pszName, "CON", 3) == 0 ||
 90         _strnicmp (pszName, "NUL", 3) == 0 ||
 91         _strnicmp (pszName, "PRN", 3) == 0)
 92       // AUX, CON, NUL, PRN のいずれかに該当。
 93       bResult = TRUE;
 94   } else if (offsetDot == 4) {
 95     if ((_strnicmp (pszName, "COM", 3) == 0 ||
 96          _strnicmp (pszName, "LPT", 3) == 0    ) &&
 97         pszName[3] >= '1' &&
 98         pszName[3] <= '9')
 99       // COM1〜9, LPT1〜9 のいずれかに該当。
100       bResult = TRUE;
101   } else if (offsetDot == 6) {
102     if (_strnicmp (pszName, "CLOCK$", 6) == 0)
103       // CLOCK$ に該当。
104       bResult = TRUE;
105   }
106
107   // 照合結果を返す。
108   returnbResult;
109 }
まとめ
Windowsには,同一のファイルを表す複数通りのパス名の書き方がある。こうした別名のいくつかは\\?\を用いて無効にできるが,ショートネームについては別の取り扱いが必要だ。ファイルのパス名の中に含まれていると具合が悪いものとしてNTFSのストリーム名や予約デバイス名がある。プログラムが予期しない動作をしてしまうのでこれらは排除しなくてはならない。
参考文献
『CreateFile関数,プラットフォームSDK ファイル入出力』
『File Name Conventions』(英文)
『INFO: Types of File I/O Under Win32』(英文)
『INFO: Filenames Ending with Space or Period Not Supported』(英文)
『How to Disable Automatic Short File Name Generation (Q210638)』(英文)
『HOWTO: Use NTFS Alternate Data Streams』(英文)
『INFO: CreateFile() Using CONOUT$ or CONIN$』(英文)
『HOWTO: Specify Serial Ports Larger than COM9』(英文)
『INFO: Accessing a Device on Windows 2000 Terminal Server Through CreateFile()』(英文)