UNIX プログラミングの基礎知識
インターネットが広く普及するにつれ、セキュリティー問題についておおく議論されるようになりました。問題の多くは、プログラマーの不注意によるものがほとんどです。たとえば、不適切な動作を行うレベルの低いコードや複雑なコードを記述する、配列の長さの制限のチェックを怠る、使用する変数の符号付/符号なしの使い分けがいい加減など、プログラミングの誤りが原因で発生します。では、プログラミングでは、どんなことに注意しておけばよいのでしょうか?
はじめに
プログラミングをする上での注意点を挙げる前に、以下の点を行っておくことが重要です。
どのようなものを作るか確実に理解しておく
コーディング前に慎重に設計する
実行環境や周辺の環境を把握する(把握したからといって、動作環境を仮定するようなプログラミングは避けてください)
発生する恐れのあるすべてのエラーとそれらを処理する方法をできる限りリストアップしておく
仕様書を作成する(または、マニュアルやドキュメントを作成しておく)
また、使用する言語の特性を知ることでケアレスミスをなくす。
たとえば、C言語などでは、
x の値をポインタp が示した値で割った結果を y に代入するといった式を記述する場合
y = x/*p /* p は除数を指す */;
これでは、コンパイラは y = x と解釈し、それ以降はコメントと解釈してしまうので
y = x / (*p) /* p は除数を指す */;
と記述するなど、括弧をうまく利用する必要があります。
また、if - else 文などで {, } をしっかりわかるようにインデントをきちんと利用するなどの配慮が必要です。
さらに、コーディングのスタイルを決めておき、他の人が読みやすいコード体系(インデントやカラム数、わかりやすい変数名)で記述したり、コメントを十分に記述することも大切です。特に複数人で開発する場合、プログラム方法(スタイル)やデバッグ方法などを統一化しておくことで、プログラムの作成を引き継いでも用意にソースを読むことが出来ます。決して、IOCCCに出展するようなコードを書いてはいけません。(^_^;)
余談ですが、プログラミングの世界で「MVCモデル」というものがあります。これは、「モデル」「ビュー」「コントローラー」の頭文字を取ったもので、「モデル」はロジック部分、「ビュー」は見栄えの部分、「コントローラー」は入力を扱いモデルとビューをコントロールする部分になります。このモデルを意識して、アプリケーションを作る(ソースを別ファイルにしたり、作業分担の単位を MVCモデルで分けるなど)ことで、柔軟性がありメンテナンスが容易で品質の高いシステムが構築できるようになります。
また、作業効率や製品の品質を向上するプログラミングメソッドとして「Extreme Programming」というものを実践するとよいかもしれない。鍵となるルールは以下のようなもの、詳しくは原文を読んで下さい。
全ての機能を盛り込んだ製品を作るのではなく、まず、基本的なバージョンとして製品をリリースし、さらに開発を続けて機能を順次追加していく。この方法により、市場投入までにかかる時間についての問題は解消でき、顧客としてもバグの少ない製品を購入できる。そして、必要に応じて機能を追加することができる。
プログラマーと顧客は、直接的な関係を維持するようする。製品に何千もの機能を詰め込むのではなく、顧客は必要な特定の機能を正確にプログラマーに伝えることでプログラマーが少数の機能を完璧に仕上げることに集中できる。
また、プログラマーは、作ろうとしているプログラムのテスト項目を用意してからコード記述に取りかかる。『品質確認』つまりプログラムのテストを独立した形で行うのではなく、プログラマーが始めに用意したテストを使うことで時間を節約できる。
コード記述中は、プログラマーは「リファクタリング」と呼ばれるテクニックを使用する。これは、常にコードを簡素化する作業を伴う。コードを整然とした状態に保ち、定義を1度しか行わないようにすることで後から対処しなければならないエラーの数を減らすことが可能になる。
ここでの記述は、すべてのプログラミングに当てはまるわけではありません。開発しようとしているシステムが、何を重要視(スピード、堅牢性、安全性、テストのしやすさ、保守性、単純さ、再利用性、ポータビリティ、コストなど)しているのかを見失なわずに、優先順位の高い項目をクリアし目的に合ったプログラミングを行なって下さい。また、ここに記述されていることだけを守れば完全なネットワークプログラムができるわけではありません。プログラミングする際に考慮しておく必要があると思われることを記述しています。
セキュリティー関連のバグを回避するプログラミング
一般的なセキュリティー関連の欠陥に「バッファオーバーフロー」があります。プログラムでバッファオーバーフローを許すと、攻撃者が悪用できてしまいます。(バッファが C 言語のローカル変数であった場合、オーバーフローを利用して攻撃者の好きなコードを実行する関数を呼ぶことができます。)バッファオーバーフローとなる原因は、値の集合(文字列)を固定長の バッファに書き込む際に、バッファの終端を超えて書き込み続けたときです。バッファオーバーフローに対して安全な Perl, Python, Ada95 といった言語を利用すると言う方法もありますが、UNIX 自体が C 言語で書かれていたり、リソースが豊富なため現在ほとんどのデベロッパーが C, C++ 言語を利用しています。(※ Perl や Ada95 を利用したからといって完全に安全なプログラミングができるわけではありません。)では、C, C++ 言語を使う上でどんなことに注意したら良いのでしょうか?
引数のチェック | 渡される引数をすべてチェックするようにしておくことで、多くのセキュリティーに関する問題を回避できます。
|
||
標準関数 | 任意の文字列を扱う場合、バッファサイズをチェックしない関数は使わない。 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()関数も、引数の長さをチェックするバージョンのものを利用する。 |
||
全ての文字列を動的に割り当てる | 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 変数をチェックし、エラーを起こした原因を調べる。ログ収集して終了することで、問題点の追跡に役に立ちます。 |
||
UNIX 環境変数 | UNIX の環境変数に依存する設計はしない。安全な方法は、シグナル、umask、カレントディレクトリー、環境変数などすべてを明示的に設定する。
|
||
一貫したチェック | エラーチェックなどは、多くのやり方でチェックするのではなく一貫性のあるものにする。assert()マクロを利用すると便利です。エラーが発生しプログラムを終了する場合、テンポラリーファイルの消去やファイルロックの解除は忘れないようにする。 |
||
ログの収集 | ログ情報を特定のログファイルに書き込むようにする。ログの収集は、足りないよりも多すぎるぐらいのほうが後々役に立ちます。syslogの機能を利用するのもよい。しかし、バッファオーバーフローを避けるために、syslog()関数に渡す引数の長さをチェックすることを怠らない。 |
||
ユーザー入力 | ユーザーの入力する文字をチェックする。この場合、問題のある文字をチェックするのではなく、問題のない文字から構成されているかチェックするようにする。(問題となる文字をチェックしていてはキリがない。) |
||
動作環境 | 動作環境を仮定してプログラミングをしてはならない。通常は、こういう環境で利用するから(させるから)、などど仮定してのプログラミングは非常に危険です。必ずしも想定した環境で動作するとは限らないので、環境が異なった場合の処理もきちんとプログラミングする。 |
||
デッドロックやシーケンス | 作成したプログラムが複数同時に実行される可能性があることを忘れてはいけない。変更ファイルをロックしている状態でプログラムがクラッシュした場合に、デッドロックにならないようにロックを解除する方法を用意しておく。 |
||
コアファイル | コアファイルには、秘密にしている情報が書き出される可能性があるので、テスト以外にプログラムがコアダンプを行うようにしてはいけない。setrlimit()関数でコアファイルのサイズを 0 に制限する。 |
||
シェルを利用する関数 | system(), popen() 関数を利用しない。どちらも内部でシェルを起動するため、おかしな引数や環境変数のために予期せぬ結果になる可能性がある。また、プログラムでどうしてもシェルを必要とする場合、Cシェルや Bash, tcsh を利用するのは避け、問題の少ない rsh, ksh を利用する。 |
||
open() システムコール | open() システムコールを使用してファイルを新しく作成する場合、O_EXECL | O_CREAT フラグを使い、ファイルが存在した場合エラーにする。 |
||
ファイル | プログラム内で使用する、ファイルやプログラムはフルパスで指定する。 |
||
テンポラリーファイル | テンポラリーファイルを作成する場合、mkstemp()関数を利用する。mktemp() 関数にはセキュリティー上問題のあるものが多いので使用しない。 良いテンポラリーファイルの作成/利用方法:
|
||
ディレクトリー | 誰でも自由に読み書きできるディレクトリーにファイルを作成しない。 |
||
チェック/テスト | コード全体のレビューを行う。このとき、自分が攻撃する場合どうするかなどを考慮しながらチェックすると良い。また、別の人にレビューをしてもらう。
コンパイルオプション Codecenter(Saber-C), Purify などのデバッグ/テストツールを利用する。これらのツールは、非常に強力で利用価値の高いものです。しかし、高額なのが難点です。 |
ネットワークプログラムのプログラミング
ポート番号 | サービスで利用するポート番号を直接プログラムに埋め込んではいけない。getservbyname()関数などを利用して値を得るようにする。 |
受信パケット | 予約された特権ポートから送られてくるからといって、そのパケットが正規の通信によって送られていると信用してはいけない。 受信パケットに記されているソース IP アドレスが絶対に信頼できるものであると思ってはいけない。偽造されている可能性があります。 |
IP アドレス | 受信パケットの IPアドレスに関するホスト名を取得した場合、そのホスト名に対して再度 IP アドレスを検索して受信した IP アドレスと一致しているか確認する。逆の場合でも、IPアドレスからホスト名の逆引きを行う。 |
過負荷への対処 | システムを使用不可能にする攻撃に対処するため、過負荷対策を用意しておく。
Distributed Denial of Service (DDoS) 攻撃など、ネットワーク使用不能攻撃などの対処も必要だが、これらの攻撃を受けにくくする方法はありません。現時点では、攻撃の影響を最小限に抑えることが最善策です。たとえば、サブネットに分割して、一つのサブネットが過負荷状態になっても別のサブネットでサービスを利用できるようにするなど。 |
タイムアウト | ネットワークを介した読み出し要求には、適切なタイムアウトを設定する。リモートサーバーから直ちに応答が返ってくるわけではない。そこで、応答を待つが、何日も応答を待つのはナンセンスなので適切な時間で強制終了するようにタイムアウト時間を設定する必要がある。 ネットワークを介した書き込み要求には、適切なタイムアウトを設定する。これも、ファイルをオープンし、データを数バイト書き込んだまま止まりデッドロックになるなどといった事を防ぐために、適切なタイムアウトを設定する必要がある。 |
データー | 入力データーに関しては、それが何処から送られてきたものであっても、何らかの仮定をして処理を行うようにプログラミングしてはいけない。たとえば、ヌル文字で終端している、改行が含まれている、ASCII形式であるなど。プログラムは、ランダムなバイナリーデータを受信した場合でも、予期した入力があった場合でも一定の動作をするようにプログラミングする。 データー量を仮定してはいけない。個々の項目やデータの総量については、長さチェックなどの制限に関する確認を必ず行う。 ユーザー認証などのためのパスワードを、暗号化せずにネットワークを介してはならない。 |
アルゴリズム | 自分で暗号化関数を作成することは避ける。これらは、信頼のある MD5, SHA-1, 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() で囲む。 /* root になる */ setuid(0); fd = open("/etc/abcfile", O_RDONLY); /* root を止める */ setuid(-1); /* エラー処理 */ if (fd < 0) error(); |
パイプやシェル | パイプやシェルを使用しなくてはならない場合、環境変数 PATH や IFS に注意する。可能ならこれらの環境変数に安全な値をセットする。 putenv("PATH=/bin:/usr/bin:/usr/ucb"); putenv("IFS= \t\n"); 必ず環境変数をすべてチェックして不要な環境変数を排除する。 |
ファイル | プログラム内で使用する、ファイルやプログラムはフルパスで指定する。カレントディレクトリーについては、仮定せずに必要なら chdir() システムコールを実行する。その際に、リターンコードをチェックする。 |
ライブラリー | 可能なら、プログラムをリンクする際にライブラリーとスタティックリンクする。共有ライブラリーを利用するダイナミックリンクは、セキュリティー上非常に危険です。スタティックリンクができない場合、共有ライブラリーを置き換えられないような予防措置が必要です。 |
chroot()関数 | chroot()システムコールを利用することで、プログラムが余計なディレクトリーを参照できなくなるのでセキュリティーを高めることができる。
|
その他
パスワード入力 | パスワード入力の際にエコー表示してはならない。getpass()関数を利用することで実現できるが、最近はパスワードの入力の誤りを確認するため1文字1文字をアスタリスク(*)で表示するものが多い。ただし、他人にパスワードが何文字なのかわかってしまう恐れがある。 |
パスワードの保管 | パスワードをシステムに保管する場合、必ず暗号化する。適切な手段がない場合、crypt()関数を利用する。 |
暗号アルゴリズム | 自分で暗号化関数を作成することは避ける。これらは、信頼のある MD5, RSA, DES, PGPなどの暗号アルゴリズムがあるのでそれらを利用する。 |
乱数 | 乱数についての一般的に考慮する事柄:
|
乱数の生成 | UNIXシステムには、ランダム性を持つものから情報を得る関数がありません。一般的に擬似乱数関数を利用します。擬似乱数関数は、推定不可能な一連の出力を生成する関数で、新しい数値を生成するたびに保持している内部状態(seed)が変更されます。 乱数を生成する上での注意:
適切な乱数の取得:
|
以上、簡単にまとめました。記述にあたり、O'REILLY の Practical UNIX & Internet Security を参考にしました。O'REILLY の Nutshell シリーズとして知られるこれらの書籍の登場は、UNIX の参考書は UNIX のソースといわれていたときに目から鱗がこぼれる思いをしたものでした。O'REILLY の書籍は良くまとまっており、UNIX の勉強をするには最適の書籍です。