UNIX プログラミングの基礎知識

インターネットが広く普及するにつれ、セキュリティー問題についておおく議論されるようになりました。問題の多くは、プログラマーの不注意によるものがほとんどです。たとえば、不適切な動作を行うレベルの低いコードや複雑なコードを記述する、配列の長さの制限のチェックを怠る、使用する変数の符号付/符号なしの使い分けがいい加減など、プログラミングの誤りが原因で発生します。では、プログラミングでは、どんなことに注意しておけばよいのでしょうか?

はじめに

プログラミングをする上での注意点を挙げる前に、以下の点を行っておくことが重要です。

また、使用する言語の特性を知ることでケアレスミスをなくす。

たとえば、C言語などでは、
x の値をポインタp が示した値で割った結果を y に代入するといった式を記述する場合

     y = x/*p      /* p は除数を指す */;

 これでは、コンパイラは y = x と解釈し、それ以降はコメントと解釈してしまうので

     y = x / (*p)  /* p は除数を指す */;
と記述するなど、括弧をうまく利用する必要があります。

また、if - else 文などで {, } をしっかりわかるようにインデントをきちんと利用するなどの配慮が必要です。

さらに、コーディングのスタイルを決めておき、他の人が読みやすいコード体系(インデントやカラム数、わかりやすい変数名)で記述したり、コメントを十分に記述することも大切です。特に複数人で開発する場合、プログラム方法(スタイル)やデバッグ方法などを統一化しておくことで、プログラムの作成を引き継いでも用意にソースを読むことが出来ます。決して、IOCCCに出展するようなコードを書いてはいけません。(^_^;)

余談ですが、プログラミングの世界で「MVCモデル」というものがあります。これは、「モデル」「ビュー」「コントローラー」の頭文字を取ったもので、「モデル」はロジック部分、「ビュー」は見栄えの部分、「コントローラー」は入力を扱いモデルとビューをコントロールする部分になります。このモデルを意識して、アプリケーションを作る(ソースを別ファイルにしたり、作業分担の単位を MVCモデルで分けるなど)ことで、柔軟性がありメンテナンスが容易で品質の高いシステムが構築できるようになります。

また、作業効率や製品の品質を向上するプログラミングメソッドとして「Extreme Programming」というものを実践するとよいかもしれない。鍵となるルールは以下のようなもの、詳しくは原文を読んで下さい。

ここでの記述は、すべてのプログラミングに当てはまるわけではありません。開発しようとしているシステムが、何を重要視(スピード、堅牢性、安全性、テストのしやすさ、保守性、単純さ、再利用性、ポータビリティ、コストなど)しているのかを見失なわずに、優先順位の高い項目をクリアし目的に合ったプログラミングを行なって下さい。また、ここに記述されていることだけを守れば完全なネットワークプログラムができるわけではありません。プログラミングする際に考慮しておく必要があると思われることを記述しています。

* セキュリティー関連のバグを回避するプログラミング

一般的なセキュリティー関連の欠陥に「バッファオーバーフロー」があります。プログラムでバッファオーバーフローを許すと、攻撃者が悪用できてしまいます。(バッファが C 言語のローカル変数であった場合、オーバーフローを利用して攻撃者の好きなコードを実行する関数を呼ぶことができます。)バッファオーバーフローとなる原因は、値の集合(文字列)を固定長の バッファに書き込む際に、バッファの終端を超えて書き込み続けたときです。バッファオーバーフローに対して安全な Perl, Python, Ada95 といった言語を利用すると言う方法もありますが、UNIX 自体が C 言語で書かれていたり、リソースが豊富なため現在ほとんどのデベロッパーが C, C++ 言語を利用しています。(※ Perl や Ada95 を利用したからといって完全に安全なプログラミングができるわけではありません。)では、C, C++ 言語を使う上でどんなことに注意したら良いのでしょうか?

引数のチェック 渡される引数をすべてチェックするようにしておくことで、多くのセキュリティーに関する問題を回避できます。
  • 関数が受け取る引数の数、型が正確である
  • 引数は、バッファサイズの制限内である
  • 環境変数で渡される引数のチェック
  • mail()関数が受け取る引数のチェック
  • 終端の Null もバッファサイズに含まれている
標準関数 任意の文字列を扱う場合、バッファサイズをチェックしない関数は使わない。
    gets(line) -> fgets(line, sizeof(line), stdin)
    strcpy()   -> strncpy()
    strcat()   -> strncat()
    sprintf()  -> snprintf()
strlen() は、文字列の終端の NULL 文字が見つかることが確かでない限り利用を避ける。また、fscanf(), scanf(), vsprintf(), realpath(), getopt(), getpass(), streadd(), strecpy(), strtrns() などの関数の利用には十分に注意を払う。syslog()関数も、引数の長さをチェックするバージョンのものを利用する。
Miller氏と de Raadt氏が開発した strlcpy() と strlcat() を利用するとよい。また、AT&T が作成し現在 Avaya Inc. のプロジェクトである libsafe を利用するのもよい。
スタティックな領域へのポインタを結果として返すような関数の利用も注意する。
全ての文字列を動的に割り当てる GNU のプログラミングガイドラインで推奨されている方法で、固定長のバッファを用いずに、全ての文字列を動的に割り当てる方法がある。動的にメモリを割り当てるには、malloc(), calloc(), realloc(), free() といった関数を利用します。メモリの開放を開放を忘れると、メモリリークの原因になるので注意が必要です。特に realloc()する場合は、メモリの再確保に成功/失敗で元の領域の解放の有無が異なるので注意が必要。
    ptr = (int*)calloc(10, sizeof(int));        /* メモリを割り当てる */
    ...
    ptr2 = (int*)realloc(ptr, sizeof(int));     /* 再取得 */
    if ( ptr2 == NULL ) {
        free(ptr);        /* 再取得に失敗の場合、元の領域は有効なままなので解放する */
        return -1;
    }
    ptr = ptr2;
    ...
    free(ptr);

また、もっと安全にメモリを動的に取得するための実装として Forrest J. Cavalier III 氏による「Libmib Allocated String Functions」 がある。

システムコール システムコールからのリターンコードはすべてチェックする。エラーが生じたら errno 変数をチェックし、エラーを起こした原因を調べる。ログ収集して終了することで、問題点の追跡に役に立ちます。
システムコールごとにチェックを記述したくない場合、呼び出しを assert()マクロでラップするようなマクロを用意しておくと便利です。エラー発生時の後始末である、テンポラリーファイルの消去やファイルロックの解除は忘れないようにする。
UNIX 環境変数 UNIX の環境変数に依存する設計はしない。安全な方法は、シグナル、umask、カレントディレクトリー、環境変数などすべてを明示的に設定する。
  • 現在の環境をプログラムに渡す必要がある場合、必要な環境変数をチェックし、不要な環境変数を無効にする。必要とされる環境変数でも、きちんとチェックする。特にタイムゾーン(TZ) やロケール(locale) に関する環境変数。
  • envp をクリーンアップするよりも、新しく envp を用意して exec() に渡す。
  • 発信したシグナルが必ず受け取り可能な状態に設定されているようにする。
  • umask を適切に設定する。
  • プログラム起動時に、適切なディレクトリーについて明示的に chdir() を実行する。
  • 制限を設ける必要のあるものはすべて設定する。オープンできるファイル数やスタックサイズ、limit など。
一貫したチェック エラーチェックなどは、多くのやり方でチェックするのではなく一貫性のあるものにする。assert()マクロを利用すると便利です。エラーが発生しプログラムを終了する場合、テンポラリーファイルの消去やファイルロックの解除は忘れないようにする。
ログの収集 ログ情報を特定のログファイルに書き込むようにする。ログの収集は、足りないよりも多すぎるぐらいのほうが後々役に立ちます。syslogの機能を利用するのもよい。しかし、バッファオーバーフローを避けるために、syslog()関数に渡す引数の長さをチェックすることを怠らない。
ユーザー入力 ユーザーの入力する文字をチェックする。この場合、問題のある文字をチェックするのではなく、問題のない文字から構成されているかチェックするようにする。(問題となる文字をチェックしていてはキリがない。)
動作環境 動作環境を仮定してプログラミングをしてはならない。通常は、こういう環境で利用するから(させるから)、などど仮定してのプログラミングは非常に危険です。必ずしも想定した環境で動作するとは限らないので、環境が異なった場合の処理もきちんとプログラミングする。
デッドロックやシーケンス 作成したプログラムが複数同時に実行される可能性があることを忘れてはいけない。変更ファイルをロックしている状態でプログラムがクラッシュした場合に、デッドロックにならないようにロックを解除する方法を用意しておく。
UNIX はタイムシェアリングのため、プログラムが進行中に別のプログラムが実行されます。そこで、別のプログラムが実行されてもエラーにならないようなプログラミングをする。たとえば、ファイルのモードの変更、所有者の変更、stat の実行などをする場合、まずファイルをオープンしてから fchmod(), fchown(), fstat() システムコールを使用することでプログラム実行中にファイルが取り替えられることを回避できる。access()関数とopen()システムコールを順に続けて使用すると競合状態になるので、 access()関数を使ってファイルへのアクセスが可能か調べるのは避ける。
コアファイル コアファイルには、秘密にしている情報が書き出される可能性があるので、テスト以外にプログラムがコアダンプを行うようにしてはいけない。setrlimit()関数でコアファイルのサイズを 0 に制限する。
シェルを利用する関数 system(), popen() 関数を利用しない。どちらも内部でシェルを起動するため、おかしな引数や環境変数のために予期せぬ結果になる可能性がある。また、プログラムでどうしてもシェルを必要とする場合、Cシェルや Bash, tcsh を利用するのは避け、問題の少ない rsh, ksh を利用する。
open() システムコール open() システムコールを使用してファイルを新しく作成する場合、O_EXECL | O_CREAT フラグを使い、ファイルが存在した場合エラーにする。
ファイル プログラム内で使用する、ファイルやプログラムはフルパスで指定する。
アクセスするファイルが、ファイルでなければならないなら lstat()関数を使用してリンクでないことを確認する。しかし、ファイルとして存在しても、そのファイルが誰でもアクセスできるようなところにある場合、チェックしたものとオープンしたものの内容が変更されている可能性があることを忘れてはいけない。
テンポラリーファイル テンポラリーファイルを作成する場合、mkstemp()関数を利用する。mktemp() 関数にはセキュリティー上問題のあるものが多いので使用しない。
良いテンポラリーファイルの作成/利用方法:
  • 作成する場合、open()システムコールで O_CREAT | O_EXCL フラグを使用して一時ファイルを作る。
  • パーミッションをチェックし、他人に編集アクセスされないようにモードやオーナーを変更する。
  • ファイル名の指定は、フルパスで指定し誰でも自由に読み書きできるディレクトリーは避ける。たとえば、スティッキービットのセットされたディレクトリーを利用する(古い System V では動作しないがSGIDでうまくいくかも...とりあえず古い UNIX ではダメ!)。
※スティッキービット スティッキービットがセットされたディレクトリー内のファイルは、そのファイルの所有者、ディレクトリーの所有者およびスーパーユーザーだけが名前の変更や削除が行える。(たとえば、すべてのユーザーがファイルを作成消去できるが、他人のファイルを消去できなくするために"chmod 1777 /tmp" などとする場合が多い。)
ディレクトリー 誰でも自由に読み書きできるディレクトリーにファイルを作成しない。
チェック/テスト コード全体のレビューを行う。このとき、自分が攻撃する場合どうするかなどを考慮しながらチェックすると良い。また、別の人にレビューをしてもらう。
コンパイルオプション -Wall -Wpointer-arith -Wstrict-prototypes (GNU Compiler Collection)や lint, tcov, dbx, gdb といったツールを利用する。K&R C よりは ANSI C を利用する。
Codecenter(Saber-C), Purify などのデバッグ/テストツールを利用する。これらのツールは、非常に強力で利用価値の高いものです。しかし、高額なのが難点です。

* ネットワークプログラムのプログラミング

ポート番号 サービスで利用するポート番号を直接プログラムに埋め込んではいけない。getservbyname()関数などを利用して値を得るようにする。
受信パケット 予約された特権ポートから送られてくるからといって、そのパケットが正規の通信によって送られていると信用してはいけない。
受信パケットに記されているソース IP アドレスが絶対に信頼できるものであると思ってはいけない。偽造されている可能性があります。
IP アドレス 受信パケットの IPアドレスに関するホスト名を取得した場合、そのホスト名に対して再度 IP アドレスを検索して受信した IP アドレスと一致しているか確認する。逆の場合でも、IPアドレスからホスト名の逆引きを行う。
過負荷への対処 システムを使用不可能にする攻撃に対処するため、過負荷対策を用意しておく。
  • プロセスフルを避けるために、同時に一人のユーザーが実行できる最大プロセス数の設定。(MAXUPROC:ただし root ユーザー以外のユーザーが対象)
  • ディスクフル攻撃や i-ノードの使い尽くしを避けるために、クォータシステムを導入。
  • 適切なスワップ領域の確保。
  • /tmp ディレクトリーにあるファイルの定期的な掃除。
  • ソフトプロセスリミットの利用。
Distributed Denial of Service (DDoS) 攻撃など、ネットワーク使用不能攻撃などの対処も必要だが、これらの攻撃を受けにくくする方法はありません。現時点では、攻撃の影響を最小限に抑えることが最善策です。たとえば、サブネットに分割して、一つのサブネットが過負荷状態になっても別のサブネットでサービスを利用できるようにするなど。
タイムアウト ネットワークを介した読み出し要求には、適切なタイムアウトを設定する。リモートサーバーから直ちに応答が返ってくるわけではない。そこで、応答を待つが、何日も応答を待つのはナンセンスなので適切な時間で強制終了するようにタイムアウト時間を設定する必要がある。
ネットワークを介した書き込み要求には、適切なタイムアウトを設定する。これも、ファイルをオープンし、データを数バイト書き込んだまま止まりデッドロックになるなどといった事を防ぐために、適切なタイムアウトを設定する必要がある。
データー 入力データーに関しては、それが何処から送られてきたものであっても、何らかの仮定をして処理を行うようにプログラミングしてはいけない。たとえば、ヌル文字で終端している、改行が含まれている、ASCII形式であるなど。プログラムは、ランダムなバイナリーデータを受信した場合でも、予期した入力があった場合でも一定の動作をするようにプログラミングする。
データー量を仮定してはいけない。個々の項目やデータの総量については、長さチェックなどの制限に関する確認を必ず行う。
ユーザー認証などのためのパスワードを、暗号化せずにネットワークを介してはならない。
暗号アルゴリズム 自分で暗号化関数を作成することは避ける。これらは、信頼のある MD5, RSA, DES, PGPなどの暗号アルゴリズムがあるのでそれらを利用する。
プロキシー プロキシーを利用できるようにする。SOCK5 などを組み込むことにより、ファイアウォールに適合できるプログラムが構築できる。
ログイン ログインの接続、切断、接続拒否、エラー検出などの処理を適切にプログラミングする。
シグナル 適切にシグナルを処理する。たとえば、TERMシグナルにより、情報のクリーンナップなどを行い終了するといった仕組み。
ログの収集(動作履歴) エラーログとは別に、プログラムが適切に動作していることを示すメッセージを定期的に収集する仕組みを組み込む。
自己認識 プログラムのコピーが同時に複数動作しないように、ロック機能などを組み込む。競合による誤作動や情報の破壊、ログの破壊を防ぐ。

* SUID/SGID を利用したプログラミング

極論 基本的には使わないようにする。
シェルスクリプト SUIDシェルスクリプトの作成は避ける
特別なファイル SUIDを利用して特別なファイルにはアクセスしない。SGIDよるアクセスにする。どうしても SUID を利用しなければならない場合、その目的だけのためのユーザーを作成する。
独立 SUIDが必要となる部分を別プログラムとして作成し、注意深く制御、監視できるインターフェースを構築する。
範囲 SUID, SGID 許可が必要な場合、できる限りプログラムの早い段階でそれらを使用し、不要になったら Effective UID(GID) を返し、特権を破棄してもとの UID, GID での処理に戻る。
オプション SUID として実行されるプログラムは、オプションなどを用いて多くのことが指定できるようなインターフェースを付け加える事は避ける
UNIX 環境変数 セキュリティー関連のバグを回避するには」を参照
プロセスの発生 プログラムがプロセスを発生させなければならない場合、execve(), execv(), execl() システムコールだけを利用し、最大限の注意を払う。正確に実行されるプログラムを指定するために、PATH環境変数を利用する execp(), execvp() システムコールの使用は絶対に避ける。
シェルエスケープ シェルエスケープを用いなければならない場合、ユーザーのコマンドを実行する前に必ず setgid(getgid()), setuid(getuid()) を使用する。
スーパーユーザー特権 スーパーユーザー特権が必要な場合、特権が必要な部分を setuid(), setgid() で囲む。
    setuid(0);                           /* root になる */
    fd = open("/etc/abcfile", O_RDONLY);
    setuid(-1);                          /* root を止める */
    if (fd < 0) error();                 /* エラー処理 */
パイプやシェル パイプやシェルを使用しなくてはならない場合、環境変数 PATH や IFS に注意する。可能ならこれらの環境変数に安全な値をセットする。
    putenv("PATH=/bin:/usr/bin:/usr/ucb");
    putenv("IFS= \t\n");
必ず環境変数をすべてチェックして不要な環境変数を排除する。
ファイル プログラム内で使用する、ファイルやプログラムはフルパスで指定する。カレントディレクトリーについては、仮定せずに必要なら chdir() システムコールを実行する。その際に、リターンコードをチェックする。
ライブラリー 可能なら、プログラムをリンクする際にライブラリーとスタティックリンクする。共有ライブラリーを利用するダイナミックリンクは、セキュリティー上非常に危険です。スタティックリンクができない場合、共有ライブラリーを置き換えられないような予防措置が必要です。
chroot()関数 chroot()システムコールを利用することで、プログラムが余計なディレクトリーを参照できなくなるのでセキュリティーを高めることができる。
  • プログラムはスタティックリンクにする。
  • どうしてもダイナミックリンクしか利用できない場合、chroot()システムコールで制限したディレクトリー内に共有ライブラリーのコピーを置く。
  • さらに、syslog()関数を利用する場合、chroot() システムコールを呼び出す前に openlog()関数を呼び出すか、chroot()システムコールで制限したディレクトリー内に /dev/logデバイスファイルを用意する必要がある。

 

* その他

パスワード入力 パスワード入力の際にエコー表示してはならない。getpass()関数を利用することで実現できるが、最近はパスワードの入力の誤りを確認するため1文字1文字をアスタリスク(*)で表示するものが多い。ただし、他人にパスワードが何文字なのかわかってしまう恐れがある。
パスワードの保管 パスワードをシステムに保管する場合、必ず暗号化する。適切な手段がない場合、crypt()関数を利用する。
暗号アルゴリズム 自分で暗号化関数を作成することは避ける。これらは、信頼のある MD5, RSA, DES, PGPなどの暗号アルゴリズムがあるのでそれらを利用する。
乱数 乱数についての一般的に考慮する事柄:
  • 乱数である数値の各ビットが、0である可能性と 1である可能性が等しくなければならない。
  • 乱数では、0の次のビットが、0である可能性と 1である可能性が等しくなければならない。1の次のビットについても同様。
  • ビット数が大きい乱数では、ほぼ半数のビットが 0、残りの半数が 1でなければならない。
乱数の生成 UNIXシステムには、ランダム性を持つものから情報を得る関数がありません。一般的に擬似乱数関数を利用します。擬似乱数関数は、推定不可能な一連の出力を生成する関数で、新しい数値を生成するたびに保持している内部状態(seed)が変更されます。
乱数を生成する上での注意:
  • 最も重要なのは、予測不可能でなければならない。
  • 限られた領域を seed としてはならない。UNIX の rand()関数は利用しない。
  • UNIX の random(), drand48()関数は、ゲームなどのプログラムには良いがセキュリティー関連のプログラムには適さない。
  • システムのクロックのみに頼ってはいけない(意外と正確ではないので)。
  • Ethernetアドレスや MACアドレスなどの法則性のある番号は利用しない。
  • 大規模なデーターベースからランダムに選択して seed として利用するのは、悪意のあるものに操作される恐れがあるので避ける。
適切な乱数の取得:
  • 放射線源、熱音源などのような、もともとランダム性を持つものから得る。
  • ユーザーのタイプするキーストロークの間隔時間を収集する。
  • マウスの移動を利用する。
  • 仮想メモリページ数やネットワークのステータスのような、簡単に利用できる上、常にランダムに変化する情報を利用する。
  • /dev/audio デバイスが拾うランダムな電気ノイズを利用する。その際に、十分に圧縮して系統だったゆがみを取り除くことでさらにランダム性が増します。
    (cat /dev/audio | compress - > file)
  • Linux では /dev/random, /dev/urandom というデバイスがあり、かなりランダム性の高い乱数を生成します。通常、キーボード・ドライバーのようなデバイス・ドライバーから発生します。
  • Don Mitchell 氏と Matt Blaze 氏が開発したTrueRand を利用する。TrueRandは、システム・クロックとプロセッサーの割り込み発生との間のドリフトを測定するといった(アイドル状態の時も含めた) CPU の状態のランダム性を利用します。オリジナルソース改変したソース。ただし、TrueRand は、偏りを取り除く操作を行わないので、出力データをランダム・プール(エントロピー・プール)に混入し MD5 でハッシュしてから使用するなどのような事後処理が必要です。
  • MD5のようなハッシュ関数では逆からたどられる(計算上は不可能とされている)のではと不安があるなら、ハッシュ関数に依存しない疑似乱数ジェネレーター(Blum Blum Shub ジェネレーターなど)を利用する。

以上、簡単にまとめました。記述にあたり、O'REILLY の Practical UNIX & Internet Security を参考にしました。O'REILLY の Nutshell シリーズとして知られるこれらの書籍の登場は、UNIX の参考書は UNIX のソースといわれていたときに目から鱗がこぼれる思いをしたものでした。O'REILLY の書籍は良くまとまっており、UNIX の勉強をするには最適の書籍です。