前回に引き続き、初学者のためのハッキング技術の解説を行います。今回は基礎知識の確認としまして、ハッカーの必須知識であるプログラミングの基礎について解説します。個々のプログラミング言語の文法は読者の方々のほうが私よりもよほど精通していらっしゃると予想されますので、文法よりも一般的かつ重要な概念を確認することに主軸を置きました。
これからの予定は、次回にも引き続き基礎知識の確認としてハードウェアの基礎知識を確認し、その次の回でバッファオーバフローの仕組みと実践を解説する予定です。
プログラミングとはコンピュータに与える命令を記述することです。ハッキングの技術にはなくてはならない知識であり、コンピュータを知るうえでも重要な分野です。プログラミングというと難しそうなイメージがあるかもしれませんが、概念自体は普段の人間生活で自然に活用しているものです。例えばカップラーメンを作る場合を考えてください。おそらくほとんどの人は次の手順に従ってカップラーメンを作ると思います。
このような手順はれっきとしたプログラムの考え方です。プログラムの基礎と題して解説している、後述の技術を、例としてカップラーメン作成など、身近な物事と対比して考えてもらえれば、いかにプログラミングが身近なものであるかがご理解いただけると思います。
プログラムは大きく分けて、2つの部分からなっています。ひとつはデータをどのような手順で加工し、問題を解くかを記述した部分です。これはアルゴリズム(algorithm)と呼ばれます。もうひとつは加工対象となるデータを記述した部分です。これらの概念も生活のなかでは非常に一般的なものです。例えば私たちは「りんごを食べる」という行為を行います。この例の場合、「食べる」という行為がアルゴリズムであり「りんごを」という部分がアルゴリズムの対象となるデータです。先述のカップラーメンの例で考えるなら、「カップラーメン」や「お湯」がデータであり、「注ぎ、観測し、待つ」という一連のカップラーメン作成行為がアルゴリズムであると言えます。
アルゴリズムの語源ははっきりとしてはいませんが、9世紀のバグダットで活躍した数学者アル・フワーリズミー(al-Khwarizmi)が語源だと考えられています。やはり数学者の名前が語源になっているだけあり、多くは数学的な問題解決手順がアルゴリズムと呼ばれます。
私たちは日本語や英語など、様々な言語を理解できます。コンピュータも言語を理解することはできますが、コンピュータが理解できる言語は私たちが普段使っている言語とは大きく異なります。
現在のコンピュータは皆さんがご存知のように2進数、つまり0か1しか理解できません。電気的に強い電流が流れたら0、弱い電流が流れた場合は1といったように、2つの状態しか理解できません。当然ですが、人間が理解できる言語で命令を与えてもコンピュータは理解できません。コンピュータが理解できるのは2つの値だけですので、コンピュータに命令を与える時はこの0と1の組み合わせで命令を記述します。これは機械語と呼ばれます。次に機械語の例を挙げます。
1011000000000001 1011100001001100
上記のような0と1の羅列が延々続きます。これをみて理解しやすいと思う方は稀でしょう。機械語はコンピュータが理解できる唯一の言語ではありますが、私たちは機械語以外にも多くの言語を理解できます。その中にはもっと理解しやすい言語がたくさんありますので、機械語は非常にわかりにくいように感じます。
昔はこの0と1だけで構成された機械語を直接人間が記述し、コンピュータに命令を与えていましたが、これは大変非効率です。コンピュータが登場した初期のころはトルグ式スイッチで0と1を打ち込んでいましたが、2進数は桁上がりが頻発するため桁数が長くなり、結果的に大変な労力をようすることになります。そこで桁数を少なくするため2進数を16進数で表す方法がとられました。16進数で上記の例を表すと次のようになります。
B0 01 B8 4C
2進数のときよりもスマートに記述できます。しかし、16進数で表現しても見た目が簡単になるだけで2進数と同じです。結局はCPUのために符号化された記号を人間が読み取るというところに無理があるのです。次に登場したのが、2進数の機械語に1対1で対応したアルファベットでプログラムを記述し、それをプログラミング後に機械語に変換する方法です。このアルファベットで記述する言語をアセンブリ言語といいます。アセンブリ言語で上記の機械語を記述すると次のようになります。
MOV AL, 0x01 MOV CL, 0x4C
MOVはMobileの略で「移動」という意味を持ちます。ALはレジスタと呼ばれる一時的な記憶領域です。0x01は16進数で表された数字です。MOVの意味が移動なので、機械語を知らなくても「ALに0x01を移動させる」という意味が読み取れると思います。
アセンブリ言語は機械語に比べれば人が理解しやすい言語といえます。機械語と1対1で対応しているため機械語への変換は容易に実現できますが、簡単な処理でもCPUが用意している複数の命令を組み合わせて実現しなければなりません。「ALの値を3倍しBLに代入する」という処理はアセンブリ言語では次のようになります。
ADD AL, AL ADD AL, AL MOV BL, AL
簡単な処理であるにもかかわらず3命令も使用します。私たちの感覚では「BL = AL * 3」のように記述したいと思うでしょう。そこで現在では「BL = AL * 3」のように、人間がより簡単に理解できる言語でプログラムを記述し、それを機械語に変換するソフトウェアに入力して機械語を出力します。このソフトウェアのことをコンパイラといいます。
+------------------------+ +--------------------+ | 人間が理解できる言語で |----------[変換]--------→| 機械語に変換される | | プログラムを記述 | | | +------------------------+ +--------------------+
英語ができない日本人がアメリカで本を出版したいと思ったとき、英語を学んでから英語で本を書くのは非効率です。普通は本の内容を日本語で記述し、それを翻訳者に頼んで英語に翻訳してもらい、それを出版します。下図を参照してもらいますと、コンピュータのプログラムを記述する場合となんら変わりないことがわかると思います。
+------------------------+ +--------------------+ | 日本語で本の内容を記述 |----------[翻訳]--------→| 英語に翻訳される | | | | | +------------------------+ +--------------------+
翻訳者は両方の言語に堪能でなければなりませんが、やはり母国語の方がうまく扱えますし、後から覚えた言葉は母国語より不得意な場合が多いです。コンパイラも同じで、両方の言葉を完璧に使えるわけではありません。コンパイラもソフトウェアですので、機械語を母国語とします。そしてコンパイラが理解できる言語も比較的母国語に近い、コンピュータ専用の言語だけです。
機械の理解できる機械語に近い言語は低級言語と呼ばれ、人が理解しやすい言語は高級言語と呼ばれます。低級・高級とは性能の良い悪いではなく、より詳しいところまでプログラマが制御できるかどうかです。低級言語の代表例はアセンブリ言語であり、高級言語として有名なものにC言語やBASICなどがあります。
コンパイル(Compile)とはコンパイラによって行われる、先述の例で挙げたように翻訳のような行為です。人間基準に書かれた命令を機械基準で書かれた命令へ変換することです。人間基準に書かれた命令のことをソースコードと呼びます。
C言語のコンパイルは次のような手順を経て完了します。コンパイルとは下図にあるようにソースコードを変換し、オブジェクトコードを生成する行為までを指します。オブジェクトコードはソースコードを機械語に変換しただけのもので、これだけではまだ実行はできません。実行可能な形式にするにはコンパイルの他にリンクと言う作業が必要になります。リンク作業はリンカという独立したプログラムによって行われますが、多くのコンパイラはリンカも含められて配布されているため、コンパイルと同時にリンクも行います。ですので実行が可能なロードモジュールの生成が可能です。
+------------+ |ソースコード| +-----+------+ | | ←プリプロセス---[文字列の調整] | +--------------------+ |調整済みソースコード| +-----+--------------+ +---[字句解析] | | ↓ | ←コンパイル----------+---[構文解析] | | ↓ +-----+------------+ +---[意味解析] |オブジェクトコード| | ↓ +-----+------------+ +---[最適化] | | ←リンク------[他のオブジェクトコード等の結合] | +-----+----------+ |ロードモジュール| +----------------+
ソースコードを字句ごとに区切り、使われている字句を明らかにします。例えば、「a+b-cd」という部分は「a」,「+」,「b」,「-」,「cd」という5つの字句に分解されます。コンパイラの用語では、この基本単位をトークン(token)と呼びます。C言語のソースコードで具体的な例をみてみましょう。次のようなソースコードを字句解析します。
while(count <= x){ count++; }
これはC言語のソースコードの一部です。ソースコードの意味はわからなくても結構です。字句解析の結果、次のように11個のトークンに分けられます。
ソースコードにおかしな字句が含まれているなどのエラーはここで検出されます。
文法をチェックします。字句解析で切り出したトークンを、構文規則に基づいて導出木に構成する過程が構文解析です。文法エラーはここで検出されます。
構文のチェックでは誤りかどうか判断できない部分をチェックします。例えば「c = a + b;」という命令は構文はエラーになりませんが、aがchar型でbがfloat型であった場合、演算対象の型がそれぞれ違いますからエラーになります。意味としてみた場合に間違えているかどうかで判断します。
不要な命令の省略やより高速な命令への置き換えなどを行います。簡単な例では「5+5+5+5」という4命令を「5*4」という1命令に置き換えるような処理のことです。近年のコンパイラは非常に高性能な最適化ルーチンを搭載しており、並みの人間が最適化を行うよりも高度な最適化を行える場合もあるようです。
C言語は1972年にAT&Tベル研究所のカーニハン(B.W.Kernighan)とリッチー(D.M.Richie)によって開発されたプログラミング言語です。非常に汎用的な言語で、組み込みシステムからOSの記述まで様々なところで利用されています。機械語に比べて人が理解しやすい形式で記述できるため、開発効率は高いですがコンピュータはC言語を直接理解することはできません。先述のようにコンパイラを通して機械語に変換します。
現在では、C言語はプログラミング言語で最も有名な言語であり、ハッカーが熟知しておくべき言語のひとつとなっています。読者の方々は私よりよほどC言語に熟達していると思われますので、ここではC言語の特徴や知っておくべき最低限の解説を行い、細かなC言語の仕様については割愛します。
C言語は記述方法が簡単で、表現に制約が少ないという点が最も特徴的といえるでしょう。システム記述用という背景から移植性にも優れ、構造化プログラミングがしやすい点も大きな特徴です。その他にも演算子・データ構造・制御構造を豊富に備えており、高級言語でありながら低水準言語に近いビット操作も行えます。
C言語は多くの技術者が自分たちが書きやすいように仕様を変えてきたため、方言のような派生した書き方が無数に存在しました。これは表現の多様化という見解からは利点となりますが、はじめてC言語にふれる人にとっては混乱を招く原因になりかねません。現在では1989年に機能が改良・拡張された標準規格がANSIによって制定され、ANSI-Cとして広まっています。ここでもC言語の規格はANSI-Cに沿った文法で解説します。
一般的にC言語にはエラーを自動で処理する機能はありません。エラーが発生した場合の処理はプログラマーが自分で考え、自分で実装しなければなりません。
実際にC言語で簡単なプログラムを書いてみましょう。次に解説するプログラムは初めて作成するC言語プログラムの定番です。テキストエディタで以下のコードを打ち込んでください。改行やスペースの挿入は適当にしてもらって結構ですが、改行は単語の途中等ではしないようにしてください。先の説明で出てきた言葉を使うと、トークンの途中で改行を入れることはできません。また、ソースコード中には全角文字を含めることはできません。全角文字を使うときは「"」で囲むか、コメントアウトしなければなりません。
入力できたらhello.cという名前で保存してください。
/**********************************/
/* 2006-12-13 Defolos hello.c */
/**********************************/
#include <stdio.h>
int main (){
printf("Hello hackers!\n");
return 0;
}
このレポートではLinux上での作業を想定しています。Linuxには標準でC言語のコンパイラであるgccがインストールされていますので、コンパイルするには次のようにコマンドを入力します。
defolos@glazheim:~$ gcc -o hello.exe hello.c
gccのoオプションはコンパイルによって生成される実行形式のファイルの名前を変更するオプションです。oオプションを省略するとコンパイル後の実行形式ファイル名は指定できず、自動的にa.outあるいはa.exeという名前になります。ここではhello.exeという名前に変更しています。最後に指定されているhello.cは元となるソースコードを指定します。
コンパイルとはソースコードを翻訳し、オブジェクトコードを生成する行為のことをいいますが、gccは自動的にリンクまで行いますので実行可能なファイルが生成されます。コンパイルによって生成された実行可能ファイルを実行すると次のようになります。
defolos@glazheim:~$ ./hello.exe Hello hackers!
実行すると画面に「Hello hackers!」と表示されました。上記のサンプルプログラムは「Hello hackers!」と表示するプログラムでしたので、予期した動作です。プログラム名の先頭に「./」という記号がついていますが、これはカレントディレクトリを意味する記号です。Linuxはセキュリティを考慮した結果、システムに登録されていないプログラムは全てディレクトリを指定して実行する必要があります。
「/* Comment */:は、「/*」と「*/」で囲まれた部分をコメントとします。つまり、注釈として人が読むための部分で、コンピュータには見えない部分です。同じように// Comment のように「//」より右から改行までをコメントとしますが、こちらはC++で定義されているコメントですからC言語環境ではエラーになることもあります。コメントは入れ子にすることはできません。例えば次のようなコメントの書き方はエラーになります。
/* コメント1
/*
コメント2
*/
*/
コメント2が入れ子になっています。コンパイラによっては入れ子のコメントを解釈するコンパイラも存在しますが、コメントの入れ子は控えるように気をつけてください。プログラミングを行っていると、デバッグのときなど上記のようにコメントを含むソースコードの一部を全部無視したい場合がでてきます。このような場合はコメントを入れ子にするかわりに次のようにすることでコンパイル時にソースコードの一部を無視することができます。
#ifdef DEBUG
/*
コメント2
*/
#endif
コメントを無視して考えると、上記のサンプルコードは次のようになります。今後はこのコードを用いて解説します。
#include <stdio.h>
int main (){
printf("Hello hackers!\n");
return 0;
}
C言語ではコンパイルの過程で、最もはじめにプリプロセッサにソースコードを渡します。プリプロセッサは渡されたソースコードの文字列調整を行います。具体的にはコメントの削除、改行のスペースへの置き換え、複数のスペースをひとつにまとめる、文字列を定義したほかの文字列に置き換えるなどの前処理を行います。プリプロセッサで文字列調整が行われたソースはコンパイラに戻され、コンパイルが続行されます。プリプロセッサもコンパイラとリンカのように独立したプログラムですが、gccはプリプロセッサも自動的に通してコンパイルを行います。
プリプロセッサで特別に処理される命令は先頭に「#」がついたものです。サンプルコードの1行目には「#include <stdio.h>」という行があります。includeという命令はプリプロセッサでは「ファイルの埋め込み」を意味する命令です。つまり、「#include <stdio.h>」という命令は「この場所にstdio.hというファイルの内容を展開する」という意味になります。stdio.hは表示・読み込み処理命令を扱うときに必要となるファイルで、画面に文字を表示させるときやキーボードから入力を読み込むときに必要になります。後述するprintfという命令は画面に文字を表示する命令ですので、このstdio.hが必要です。
プリプロセッサは改行をスペースに置き換えてしまいますので、1行の終わりは「;」で識別することになります。そのため、命令の後に「;」を忘れますとコンパイル時にエラーが発生します。
関数とは、ある値を入力すると加工して違う値を出力するもののことです。たとえば、りんごを入れると中で何やら加工してアップルパイを出してくれるような箱を思い浮かべてください。アップルパイの作り方を知らなくても、材料を入れればアップルパイが出てきます。このような仕組みのことを関数と呼びます。
関数は数学などでよく出てきますが、プログラムの関数と数学の関数はまったく別のものです。ただ動作が似ているのでこのような仕組みのことを数学用語であった「関数」と呼んでいるだけです。ただし、数学用語を用いているだけあって、りんごの例を数学的に考えることができます。りんごをx、アップルパイをyとおきます。アップルパイ製造ボックスをf()で表現するなら、f(x) = yと表すことができます。数学の教科書などでよく出てくるような形となりましたが、これはアップルパイ製造ボックスの中にx(りんご)を入力「f(x)」するとy(アップルパイ)が出力されるということを表しています。
x:りんご=入力値 ↓ +------------------+ | f() |:アップルパイ製造ボックス=関数 +------------------+ ↓ y:アップルパイ=出力値
サンプルコードの5行目には「printf ("Hello hackers!\n")」という命令があります。先述の説明ではこれは文字を画面に表示する命令だと解説しましたが、それは正確にいえば誤りです。本来画面への出力というのはよりハードウェアに近い処理が必要となるため、1行やそこらの命令では到底記述できません。
実は「printf("Hello hackers!\n")」というのは関数です。"Hello hackers!\n"という文字列をprintf()という関数に入力値として入力し、出力値は画面への表示という形で返されます。画面へ文字を出力する仕組みを知らなくても、材料を入れれば結果が出てきます。
仕組みを知らなくても結果が出てくるということには大きな利点があります。画面に文字を表示するという処理は非常に複雑な仕組みが関係して文字を表示しています(実際には私は仕組みを知りません)。これをブラックボックスとして、表示したい文字を渡せば画面に表示してくれるように隠蔽すれば、すべてのプログラマーがハードウェアに近い複雑な処理を知る必要がなくなり開発効率が向上します。
"Hello hackers!\n":入力値 ↓ +------------------+ | printf() |:関数(内部の仕組みは知らなくてよい) +------------------+ ↓ Hello hackers!:出力値
関数は処理を小分けするのにも便利です。世の中の仕事は、どんな大きな仕事であっても小さな仕事の集まりです。小さな仕事を細かく見すぎると全体を把握できなくなりますが、大まかな内容に分けてトップダウン的に仕事を考えると全体を見失うことなく大きな仕事を小分けできます。
例えば、A社に自社で販売している商品の販売契約をとるという仕事があったとします。これ自体は非常に大きな仕事です。しかし、よく見るとこれはいくつかの仕事の集まりで構成されています。
+------------契約をとる-----------------+ | | | ・上司に相談する | | ・A社にアポイントメントをとる | | ・プレゼンテーションの資料作成 | | ・契約書の準備 | | ・A社へ訪問 | | ・プレゼンテーションを行う | | ・契約 | | | +------------[仕事達成]-----------------+
どうやってA社にアポイントメントをとるかといった細かい方法はこの時点ではわかりません。しかし、全体としてどういった流れで仕事を行えばよいのかを把握することができます。小分けした仕事を関数として扱えば、細かい方法を考えずに、全体の仕事を遂行するために必要なサブルーチン(小分けした仕事)がどういうものか把握できます。さらにA社にアポイントメントをとるというサブルーチンを見ていくと次のようなサブルーチンが必要になります。
+------A社にアポイントメントをとる------+ | | | ・A社の電話番号を調べる | | ・電話をかける | | ・用件を伝える | | ・場所と日時を決める | | ・電話を切る | | | +------------[仕事達成]-----------------+
このままさらに細部まで小分けしていけばA社とアポイントメントをとる方法は明らかになります。関数の概念を用いれば、大きな仕事であっても全体を見逃すことなく細部の処理を考えることができます。
プログラミングにおいて関数化は非常に重要な概念であり、効率的に開発するのには必須の考え方です。通常ならひとつの関数は内部でさらに細かい仕事に分けられるため60行以内に収まります。
ポインタはC言語で特につまずきやすい鬼門として有名です。
変数というのはプログラムで利用するデータを格納しておく箱のようなものです。プログラム内で宣言し、利用している変数は全てコンピュータ上のどこか(正解はメモリ内のどこか)に配置されています。その場所自体のことをアドレスといいます。ここで駅のロッカーを考えてください。ちょうどロッカーは箱状になっていて内部に荷物を格納しますので、変数のようなものだと考えることができます。ロッカーには1番から順番にロッカー番号が振られていて、その番号があるおかげで私たちはどのロッカーを借りたのか探すことができます。
+--------+--------+--------+--------+--------+--------+ | 1 | 2 | 3 | 4 | 5 | 6 | | | | | | | | +--------+--------+--------+--------+--------+--------+ | 7 | 8 | 9 | 10 | 11 | 12 | | | | | | | | +--------+--------+--------+--------+--------+--------+
7番ロッカーを借りたとしますと、私たちは7番という数値だけ覚えておけばロッカー内の荷物を出し入れすることができます。ロッカーの例ではロッカー番号がアドレスという概念になります。
ポインタはアドレスを格納するための変数です。ポインタを解説するに当たり、変数の宣言時にどのようにメモリ(ロッカーと置き換えてもらって結構です)に変数のための領域が確保されるのかを見てみましょう。例えば、変数int test = 30はメモリ上に次のように確保されます。
番地 [メモリ] 1 +---------+ | | 2 +---------+ ! ! 100 . . | | 101 +---------+ | 30 |←test 102 +---------+ ! !
この例ではメモリ101番地をtestと名づけ、その番地に30を格納しています。testと名づけることでアドレス番号を指定しなくても、testという文字列で指定できるようになったわけです。このように番地に別名をつけるのはアセンブラでラベルにあたるものだと思います。ポインタの部分がわかりにくいと感じましたら、先にアセンブリの解説を読んだうえでもう一度ポインタの解説をお読みください。
この例で変数testというものは101番地であると定義されています。このtestというラベルのついた番地を他の変数に格納する仕組みをポインタといいます。ポインタも変数の一種ですから当然メモリの番地の一画に確保されているわけですが、この番地は特別な番地に確保されます。
ポインタの宣言、利用は次のように行います。
int main (void){
int test = 0; /* 変数testの宣言 */
int *pointer; /* ポインタの宣言 */
int temp = 0; /* 103番地に確保されるものとする */
pointer = &test; /* testのアドレスを格納 */
*pointer = 2600; /* 解説は後述 */
temp = *pointer + 1; /* 解説は後述 */
return 0;
}
変数の前に*をつけることでポインタだと宣言します。pointer = &test;でpointerにtestのアドレスを格納します。
番地 [メモリ] 1 +---------+ ! ! 100 . . | | 101 +---------+ | 0 |←test 102 +---------+ | | 103 +---------+ | 0 |←temp +---------+ ! ! 900 . . | | 901 +---------+ | 101 |←pointer(内容は変数testのアドレス) +---------+
ポインタを使ったデータのアクセスには*をつけます。*pointerで、pointerに格納されているアドレスの中身を表します。それ故に*pointer = 2600;で「pointerに格納されているアドレスの中身を2600で上書きする」という命令になります。
番地 [メモリ] 1 +---------+ ! ! 100 . . | | 101 +---------+ | 2600 |←test 102 +---------+ | | 103 +---------+ | 0 |←temp +---------+ ! ! 900 . . | | 901 +---------+ | 101 |←pointer (内容は変数testのアドレス) +---------+
temp = *pointer + 1;は、pointerに格納されている値(ここではtestのアドレスである101)に1を足すという命令になります。それによってtempに格納される値は102になります。
番地 [メモリ] 1 +---------+ ! ! 100 . . | | 101 +---------+ | 2600 |←test 102 +---------+ | | 103 +---------+ | 102 |←temp +---------+ ! ! 900 . . | | 901 +---------+ | 101 |←pointer (内容は変数testのアドレス) +---------+
ポインタで特に理解しておくべき点は、プログラム中のデータは変数に格納され、その変数はメモリ上のどこかに必ず配置されるという点、メモリは区画分けされてデータを格納しており、アドレスを用いてそのデータにアクセスできるという点の2点です。これらの知識はハードウェアの基礎を理解するのに役立ち、アセンブリやほかの高級言語の理解を助けます。また、ハッキング手法を理解する大前提となります。
アセンブリ言語は先述のように機械語命令と一対一で対応した低級言語です。機械語の代わりにニモニックと呼ばれるアルファベット形式の命令を使ってプログラミングします。機械語と一対一で命令が対応しているので、機械語と同レベルのきめの細かいプログラミングが可能であり、なおかつ機械語よりも簡単にプログラミングを行うことができます。
C言語などの高級言語の出現、ハードウェア性能の飛躍によってアセンブリ言語は過去の産物ととられがちになりましたが、プログラムは全て一度アセンブリに変換されたうえで機械語に置き換えられます。プログラムを学んでいくうえでは決して無視できない言語です。私はアセンブリ言語は特にハッカーがC言語以上に精通しておくべき言語だと考えています。
アセンブリ言語は記述の仕方によっていくつかの種類に分けられます。現在主に使われている種類を次に列挙します。
このレポートではGASを使って解説します。理由はgccでコンパイルが可能であり、プログラマーがよくお世話になるデバッガGDBがリエンジニアリングで吐き出すコードがGASだからです。
次にGASアセンブリのサンプルコードを示します。このプログラムは「Hello World」を画面に表示するプログラムです。
.data
msg: .string "hello world\n"
len: 12
.text
.global main
main:
movl $4, %eax
movl $1, %ebx
movl $msg, %ecx
movl $len, %edx
int $0x80
ret
アセンブリでは変数は「.data」と書かれた部分から「.text」と書かれた部分までの間で宣言します。正確には変数ではなくラベルという概念です。msgという変数はASCII文字列でhello world\nという内容を格納した変数と扱えます。「.data」と書かれた部分はデータセグメントと呼ばれるデータ格納用のメモリ領域に確保されます。セグメントの詳細については次回解説しますので、ここでは変数は「.data」から「.text」までの間で宣言しなければならないと覚えておいてください。
ラベルはメモリ番号に別名をつけることです。ロッカーの例では、7番ロッカーと呼ぶかわりに「鈴木のロッカー」と呼びかえるようなものです。別名をつけることで、番号で呼ぶよりも何のためのロッカーなのかが明確になりました。msgの宣言でメモリが次のようになります。
番地 [メモリ] 100 . . | | 101 +---------+ | 103 |←msgと別名をつけた 102 +---------+ | | 103 +---------+ | H |←文字列の先頭アドレス 104 +---------+ | e | 105 +---------+ ! l !
プログラムのコード自体は「main:」以降に記述します。アセンブリのプログラミングではOSが提供するシステムコールを利用して様々な処理を行います。画面に文字を表示する処理やファイルの内容を読み込むなどの処理はシステムコールを介して行われます。システムコールとは関数のようなもので、OSが提供する機能をユーザが利用しやすいようなインタフェースとしたものです。たとえば、OSが文字を表示する仕組みはわからなくとも定められた値を設定してシステムコールを呼べばOSが文字を表示してくれます。システムコールにはそれぞれ番号が割り振られており、文字を出力するwriteシステムコールは4番になっています。Li nuxのシステムコール番号の対応表は/usr/src/linux-2.4.18/include/asm/unistd.hに保存されています。次にunistd.hの一部を挙げます。
#ifndef _ASM_I386_UNISTD_H_
#define _ASM_I386_UNISTD_H_
/*
* This file contains the system call numbers.
*/
#define __NR_exit 1
#define __NR_fork 2
#define __NR_read 3
#define __NR_write 4
#define __NR_open 5
#define __NR_close 6
#define __NR_waitpid 7
#define __NR_creat 8
#define __NR_link 9
#define __NR_unlink 10
#define __NR_execve 11
#define __NR_chdir 12
…(略)…
システムコールを利用するには、eaxに利用したいシステムコール番号を指定し、ebxにそのシステムコールが要求する第1引数を、ecxに第2引数を設定し、その後int $0x80を実行して割り込みを発生させます。eaxやebxはレジスタと呼ばれる高速な記憶領域をあらわしていますが、変数のようなものだと思っていただければ結構です。
+-----------------------+---------------+---------------+---------------+ | eax | ebx | ecx | edx | +-----------------------+---------------+---------------+---------------+ | システムコール番号 | 第1引数 | 第2引数 | 第3引数 | +-----------------------+---------------+---------------+---------------+
Manpage(http://www.linux.or.jp/JM/html/LDP_man-pages/man2/write.2.html)でwriteシステムコールを検索すると、書式は次のようになっています。
ssize_t write(int fd, const void *buf, size_t count);
上記より第1引数にはファイルディスクリプタを指定し、第2引数には文字列が格納されているアドレスの先頭、第3引数は出力する文字の数であることがわかります。標準出力はファイルディスクリプタ1番と定義されていますので、次のように設定して割り込みを発生させればよいことになります。msgやlenは自動的にアドレスが渡されることになります。msgのアドレスには文字列が格納されている先頭アドレスが格納されています。
+-------+-------+-------+-------+ | eax | ebx | ecx | edx | +-------+-------+-------+-------+ | 4 | 1 | msg | len | +-------+-------+-------+-------+
割り込みが発生した後はOSが提供するシステムコールに処理が移行し、画面に文字が表示されます。システムコールは関数であり、このとき文字列のアドレスや出力する文字の長さなどはスタックを用いて受け渡されます(スタックの詳細については次回を参照)。割り込みはレジスタに必要な引数がセットできたことをOSに伝え、システムコールへ処理を移行させるための合図です。
今後のアセンブリプログラミングにおいてもシステムコールを多用して必要な処理を行っていくことになります。
ソースコード中の「movl $4, %eax」という命令を例にとって解説します。movlの部分が命令で「〜を〜に移動させる」という意味を持ち、$4および%eaxが命令に関係するデータです。$4は4という数字自体を現しており、%eaxはeaxレジスタを表しています。「$」は数字や文字列を表し、「%」はレジスタを表す記号と思ってもらえれば結構です。
主に命令の表記の仕方には2つの方法があります。ひとつはIntel形式と呼ばれる記述方法で、もうひとつはAT&T形式と呼ばれる記述方法です。前者はMASMやNASMで採用されている記述方式で、後者はGASに採用されています。この2つの方式の違いはごく簡単なもので、命令の後に続けるデータの順番が逆になるだけです。例えば「mov a b」のような命令は、Intel形式では「aにbを代入」と解釈されますが、AT&T形式では「aをbに代入」と解釈されます。これだけの違いであり、アセンブリ言語の種類によってどちらか一方の固定ですので、どちらの記述方法を採用しているか知っていれば、特に難しいものではありません。
ここではGASを使っていますので、AT&T形式で解釈します。つまり、「eaxレジスタに4を代入する」という命令であることがわかります。同様に、「movl $1, %ebx」は「ebxレジスタに1を代入する」という命令です。少し毛色が違うのは「movl $msg, %ecx」という命令です。$msgというのは数字ではなく、.dataのところで定義したmsgという配列(正確に言えばアドレス)に宣言された文字列を表しています。つまり、「ebxレジスタにmsg配列の先頭アドレスを代入する」という命令と等価であるといえます。
次の「int $0x80」のint命令(interrupt)とは割り込みを発生させる命令です。関数をここで呼び出すことをOSに通知するシグナルのようなもので、0x80番シグナルは先述のシステムコール割り込みの発生を表しています。ここでシステムコール番号4番のwriteシステムコールが呼ばれることになり、画面へ文字列を表示します。
最後の「ret」は、ここでプログラムが終わりであることを表す特殊な命令です。基本的にはプログラムの終わりにこの命令を配置することになります。
少し駆け足でサンプルコードの実行される様子を見てきましたが、ここで理解しておいてほしいところは実際のアセンブリプログラミングの雰囲気と次の2点です。
アセンブリプログラミングの知識は第3回以降の解説で必ず使います。具体的にはシェルコードの開発でアセンブリプログラミングの応用技術を使います。この解説では文法については触れていませんので、よく分からなかったという方や文法を知らないという方は他の書籍を参考に知識の補充をお願いします。私のちゃちな解説よりもよほど洗練された解説をされている書籍は多いと思いますので、文法の解説は割愛します。
今回は前回に引き続き、前提知識の確認を行いました。ハッキングにおいて特に本質となるプログラミングを重点にC言語とアセンブリ言語について解説をしました。次回はハードウェアについての基礎部分を解説し、前提知識の確認を終えます。まだ、いかにもハッキングといった内容の解説は出てきませんが、前提となる知識を詰めることがハッキングの理解を助けるのだと考えています。