バッファーオーバーラン (バッファーオーバーフロー) の脆弱性をついた攻撃に入門する


0. 概要

脆弱性の基本的な存在、バッファーオーバーラン。簡単に言うと、メモリにロードされたプログラムを書き換えてしまうといったものである。
本来はバグであるものの、使い方によっては攻撃にも応用可能なため、脆弱性と呼ばれているわけである。今回はポインタの理解も含めて、C言語でバッファーオーバーランを説明する。

1. アドレス空間

アドレス空間について理解せずともバッファーオーバーランのプログラムを書くことはできるが、OSが提供するメモリ管理の基本なので概要だけでも掴んでおいた方が良い。
まず大きく分けて、アドレス空間にはカーネル空間ユーザ空間がある。

カーネル空間
カーネル空間は名前の通り、OS起動時にOSの重要な機能が読み込まれるメモリ領域のことである。
なお、Linux等の場合はカーネル空間→ユーザ空間(のプロセス)へ任意のシグナルを送ったりといった特権がある。

ユーザ空間
ユーザ空間は、プロセス(アプリケーション)起動時にそのプロセス情報が展開されるメモリ領域のことである。プロセス毎にページテーブルを持っており、メモリ領域はプロセス間で共有されない。一方、OSの情報は各プロセスで必要であったりするので、それらは共有的に使えるようカーネル空間の仮想アドレスがマッピングされている。


https://milestone-of-se.nesuke.com/sv-basic/architecture/user-space-kernel-space/

この説明からプロセスを超えてメモリを操作するのは難しいことや、ましてカーネル空間のメモリを操作することは難しいといったことが分かる。
基本的にメモリ空間に対する攻撃の初手は、攻撃するアドレスの予測を行うことであるが、アドレス空間配置のランダム化(ASLR)というデータ配置を無作為に行う技術も登場しておきり、攻撃は難化していっている。

2. バッファーオーバーラン

今回バッファーオーバーランを行う対象は、ユーザ空間に展開されたあるプロセスが入力を受け付けており、
それに対しバッファーオーバーランを起こさせるデータを入力して、データ入力を受け付けていない変数にこちらの意図した値を入力するといったことを試みたい。

2.1. 入力受付のプログラム

バッファーオーバーランを行うための素体プログラムを作る。入力を受け付けるプログラムを以下のように作る。

#include <stdio.h>
int main() {
    int a = 0xFFffFFff;
    char buf[4];
    fgets(buf, 128, stdin);
    return 0;
}

fgetsは入力データをbufにコピーする関数で、さらにデータサイズも指定できるセキュアな関数である。
しかし、バッファーオーバーランを起こすため今回はchar buf[4]で確保したデータサイズより
大きな128バイトのデータまでを受け入れられるようにしている。

今回はこのbufをオーバーフローさせてaの変数を書き換えるということを行う。
仮にゲームであれば、名前入力でオーバーフローを起こさせて、何らかのステータスパラメータaを好きな値にするというストーリーである。

2.2. アドレス配置の確認

では、bufをどれくらいオーバーフローさせればaを好きな値に書き換えられるのか、アドレスの配置を調べたいと思う。
そこで、以下のようにプログラムを書いて、各変数の先頭アドレスと中身を調べてみる。

#include <stdio.h>

void dispAddress(char *base) {
    for (int i = 0; i < 20; i++) {
        printf("[%p]: %02X \n", (base+i), *(base + i) & 0x000000FF);
    }
}
int main() {
    int a = 0xFFffFFff;
    char buf[4];

    printf("Buffer Address (hex): %p\n", &buf);
    printf("A Address (hex): %p\n", &a);
    printf("A value (hex): %p\n", a);
    dispAddress(buf);
    printf("\n");
    fgets(buf, 128, stdin);
    printf("%x %s\n", a, buf);
    return 0;
}

アドレス番地と実体を1バイト単位で調べたいので、dispAddress関数はchar型を使っている。x86でint型を用いると4byte刻みになるので、分かりにくい。実行してみると以下のようになるはずである。

なお、相対アドレスは変わらないと思うが、絶対アドレスは実行環境によって変わると思うので、そこは適宜補完してほしい。

Buffer Address (hex): 007CFABC
A Address (hex): 007CFAC8
A value (hex): FFFFFFFF
?[007CFABC]: CC
?[007CFABD]: CC
?[007CFABE]: CC
?[007CFABF]: CC
?[007CFAC0]: CC
?[007CFAC1]: CC
?[007CFAC2]: CC
?[007CFAC3]: CC
?[007CFAC4]: CC
?[007CFAC5]: CC
?[007CFAC6]: CC
?[007CFAC7]: CC
?[007CFAC8]: FF
?[007CFAC9]: FF
?[007CFACA]: FF
?[007CFACB]: FF
?[007CFACC]: CC
?[007CFACD]: CC
?[007CFACE]: CC
?[007CFACF]: CC

このような結果が出力されたと思う。

buf007CFABCアドレスから始まっており、本来なら4 bytes分確保されている(buf[4]と定義しているから)。

次にa007CFAC8アドレスから4 bytes分確保されていることが分かる。というのも、aの先頭アドレス007CFAC8から007CFACBアドレスの4 bytesまでを合わせると初期化した数値と同じになるからである。

bufの先頭アドレス007CFABC及びaの先頭アドレス007CFAC812 bytes離れており、なぜ5 bytes以上、無駄にアドレス番地が離れているのかは、専門家ではないため詳しくは分からない。凡そ、プロセッサがデータを取り出しやすい大きさに分割するようなアライメント境界だとかの話だと思われる。5 bytes以上なのはcharには終了を示す00が最後に自動的に入るためである。

ここで重要なのはbufからaまで離れている量は12 bytesだということである。
すなわち、それくらいの量を書き込めばaの領域を侵犯することができる。

イメージでいうと、この様な感じ。

なお、実際は上下反転して考えるべきである。a及びbufの変数宣言順序とメモリアドレスの順序が逆転していると思う。
これは変数がスタックされていっているからである。上に積みあがっていると考えればよい。

2.3. バッファーオーバーラン

これまでを踏まえてAを14個ほど書き込んでみる。
なお、書き込んだ後、メモリ内の値がどのように変化しているか確認するため、再度dispAddress関数で表示する。

#include <stdio.h>

void dispAddress(char *base) {
    for (int i = 0; i < 20; i++) {
        printf("[%p]: %02X \n", (base+i), *(base + i) & 0x000000FF);
    }
}
int main() {
    int a = 0xFFffFFff;
    char buf[4];

    printf("Buffer Address (hex): %p\n", &buf);
    printf("A Address (hex): %p\n", &a);
    printf("A value (hex): %p\n", a);

    dispAddress(buf);

    printf("Character code of a: %p \n", 'A');

    printf("\n");
    fgets(buf, 128, stdin);
    printf("%x %s\n", a, buf);

    dispAddress(buf);
    return 0;
}

実行すると以下のような結果が得られたはずである。

Buffer Address (hex): 007CFABC
A Address (hex): 007CFAC8
A value (hex): FFFFFFFF
?[007CFABC]: CC
?[007CFABD]: CC
?[007CFABE]: CC
?[007CFABF]: CC
?[007CFAC0]: CC
?[007CFAC1]: CC
?[007CFAC2]: CC
?[007CFAC3]: CC
?[007CFAC4]: CC
?[007CFAC5]: CC
?[007CFAC6]: CC
?[007CFAC7]: CC
?[007CFAC8]: FF
?[007CFAC9]: FF
?[007CFACA]: FF
?[007CFACB]: FF
?[007CFACC]: CC
?[007CFACD]: CC
?[007CFACE]: CC
?[007CFACF]: CC
Character code of a: 00000041

AAAAAAAAAAAAAA
a4141 AAAAAAAAAAAAAA

?[007CFABC]: 41
?[007CFABD]: 41
?[007CFABE]: 41
?[007CFABF]: 41
?[007CFAC0]: 41
?[007CFAC1]: 41
?[007CFAC2]: 41
?[007CFAC3]: 41
?[007CFAC4]: 41
?[007CFAC5]: 41
?[007CFAC6]: 41
?[007CFAC7]: 41
?[007CFAC8]: 41
?[007CFAC9]: 41
?[007CFACA]: 0A
?[007CFACB]: 00
?[007CFACC]: CC
?[007CFACD]: CC
?[007CFACE]: CC
?[007CFACF]: CC

まずAはASCIIコード(16進数)で41である。
14文字打ったため、先頭アドレス007CFABCから14 bytes分埋まっていることが分かる。
変数aのメモリ番地(007CFAC8007CFACB)にも侵犯して41が書き込まれている。
このため、FFFFFFを示していたaの整数はa4141という値に変化している。

これで変数aを直接操作せずとも値を変更できることが確認できた。

しかし1つ疑問が残る。以下の2つである。

?[007CFACA]: 0A
?[007CFACB]: 00

書き込んでいない余計なものまで書き込まれている。
これは文字の終了コードを示す0A00、すなわちLF (0A) + NULL (00)がメモリに書き込まれているだけである。