banner
言心吾

言心吾のBlog

吾言为心声

PWNノートの基礎

アセンブリの基礎#

習得度:アセンブリを理解し、ガジェットを分析し、ステップデバッグでレジスタの状態を理解する

参考記事:x86_64 アセンブリの一つ:AT&T アセンブリ構文_x86_64 アセンブリ at&t-CSDN ブログ

レジスタ#

一般的な x86 CPU レジスタは 8 つあります:EAXEBXECXEDXEDIESIEBPESP

CPU はまずレジスタを読み書きし、その後レジスタ、キャッシュ、メモリを通じてデータを交換し、バッファリングの目的を達成します。レジスタは名前でアクセスできるため、アクセス速度が最も速く、ゼロレベルキャッシュとも呼ばれます。

アクセス速度は高い順に次のようになります: レジスタ > 1次キャッシュ > 2次キャッシュ > 3次キャッシュ > メモリ > ハードディスク

汎用レジスタとその用途#

上記の 8 つのレジスタにはそれぞれ特定の用途があります。ここでは32ビット CPUを例にとり、これらのレジスタの役割を簡単に説明します。以下の表に整理しました:

レジスタ意味用途含まれるレジスタ
EAX累加器 (Accumulator) レジスタ主に乗算、除算、関数の戻り値に使用されるAX(AH、AL)
EBX基底 (Base) レジスタ主にメモリデータのポインタとして使用されるBX(BH、BL)
ECXカウンタ (Counter) レジスタ主に文字列操作やループ処理のカウンタとして使用されるCX(CH、CL)
EDXデータ (Data) レジスタ主に乗算、除算、I/O ポインタに使用されるDX(DH、DL)
ESIソースインデックス (Source Index) レジスタ主にメモリデータポインタやソース文字列ポインタとして使用されるSI
EDIデスティネーションインデックス (Destination Index) レジスタ主にメモリデータポインタやデスティネーション文字列ポインタとして使用されるDI
ESPスタックポインタ (Stack Point) レジスタスタックのスタックトップポインタとしてのみ使用される;算術演算やデータ転送には使用できないSP
EBP基底ポインタ (Base Point) レジスタスタックポインタとしてのみ使用され、スタック内の任意のアドレスにアクセスできる;ESP 内のデータを中継するためによく使用され、スタックにアクセスするための基底としても使用される;算術演算やデータ転送には使用できないBP

上記の表の各一般的なレジスタの後には他の名前もあります。rax、eax、ax、ah、al は実際には同じレジスタを示しており、異なる範囲を含んでいるだけです

以下は 64 ビットレジスタの対照関係です:

|63..32|31..16|15-8|7-0|
              |AH. |AL.|
              |AX......|
       |EAX............|
|RAX...................|

命令ポインタレジスタ#

命令ポインタレジスタ(RIP)は、次に実行される命令の論理アドレスを含みます。

通常、命令を 1 つ取り出すごとに、RIP は自動的に次の命令を指すように増加します。x86_64 では、RIP の増加は 8 バイトのオフセットを意味します。

しかし、RIP は常に増加するわけではなく、例外もあります。例えば、call命令やret命令です。call命令は現在の RIP の内容をスタックにプッシュし、プログラムの実行権をターゲット関数に渡します。ret命令はスタックからのポップ操作を実行し、以前にスタックにプッシュされた 8 バイトの RIP アドレスをポップして RIP に戻します。

フラグレジスタ(EFLAGS)#

image-20240331212445677

アセンブリ言語命令#

一般的なアセンブリ命令:movjejmpcalladdsubincdecandor

データ転送命令#

命令名称備考
MOV転送命令MOV dest, srcデータを src から dest に移動
PUSHプッシュ命令PUSH srcソースオペランド src をスタックにプッシュ
POPポップ命令POP destスタックのトップからデータを dest にポップ

算術演算命令#

命令名称備考
ADD加算命令ADD dest, srcdest に src を加算
SUB減算命令SUB dest, srcdest から src を減算
INCインクリメント命令INC destdest に 1 を加算
DECデクリメント命令DEC destdest から 1 を減算

論理演算命令#

命令名称備考
NOT否定命令NOT destオペランド dest をビット単位で否定
AND論理積命令AND dest, srcdest と src の論理積を dest に戻す
OR論理和命令OR dest, srcdest と src の論理和を dest に戻す
XOR排他的論理和命令XOR dest, srcdest と src の排他的論理和を dest に戻す

ループ制御命令#

命令名称備考
LOOPカウントループ命令LOOP labelECX の値を 1 減らし、ECX の値が 0 でない場合は label にジャンプ、そうでなければ LOOP の後の文を実行

転送命令#

命令名称備考
JMP無条件転送命令JMP label無条件で label に転送
CALLプロシージャ呼び出し命令CALL labellabel を直接呼び出す
JE条件転送命令JE labelzf =1 の時に label にジャンプ
JNE条件転送命令JNE labelzf=0 の時に label にジャンプ

Linux と Windows のアセンブリの違い#

linuxwindows のアセンブリ構文は異なります。実際、2 つの構文の違いはシステムの違いとは絶対的な関係はなく、一般的に linux では gcc/g++ コンパイラが使用され、windows ではマイクロソフトの cl、つまり MSBUILD が使用されます。そのため、異なるコードが生成されるのはコンパイラの違いによるもので、gcc では AT&T のアセンブリ構文形式が採用され、MSBUILD では Intel アセンブリ構文形式が採用されています。

差異IntelAT&T
レジスタ名の参照eax%eax
値の代入順序mov dest, srcmovl src, dest
レジスタ、即値命令の接頭辞mov ebx, 0xd00dmovl $0xd00d, %ebx
レジスタ間接アドレス[eax](%eax)
データ型のサイズオペコードの後に接尾辞文字を追加、「l」 32 ビット、「w」 16 ビット、「b」 8 ビット(mov dx, word ptr [eax])オペランドの前に dword ptr、word ptr、byte ptr の形式を追加(movb % bl % al)

アドレッシング方式#

直接アドレッシング

メモリアドレッシング:[ ]

オーバーフロー(符号付き & 上下オーバーフロー)#

  • ストレージビット数が不足
  • 符号ビットへのオーバーフロー

整数オーバーフローは他の脆弱性と組み合わせて使用されます。

個人的には、符号ビットの進位がオーバーフローを示すと考えています。

LINUX ファイルの基礎#

保護レベル:0-3

0 - カーネル

3 - ユーザー

仮想メモリ:物理メモリが MMU によって変換されたアドレス。システムは各ユーザープロセスに一段の仮想メモリ空間を割り当てます。

ビッグエンディアンとリトルエンディアン#

ビッグエンディアン:データの高位が計算機の低位アドレスに配置される(人間の読みやすい習慣により適合)

リトルエンディアン:データの低位が計算機の低位アドレスに配置される(直感に反するが、ストレージロジックや計算規則により適合)

コンピュータが文字列を出力する際:低アドレスから高アドレスへ

Linux のデータストレージ形式はリトルエンディアンで、ARM アーキテクチャはビッグエンディアンです。

文字列形式で数字を入力する際は、形式に注意が必要で、Linux は低から高にデータを読み込みます。pwntools を使用して変換できます。

ファイルディスクリプタ#

各ファイルディスクリプタは 1 つのオープンファイルに対応しています。

  • 0
  • 1
  • 2

stdin->buf->stdout

例えば:

read(0,buf,size)

write(1,buf,size)

スタック(stack)#

制限された配列で、片方の端のみ操作可能

データ構造:後入れ先出し(LIFO)、関数呼び出しの順序と同じ

​ 関数の開始実行:main->funA->funB

​ 関数の完了順序:funB->funA->main

基本操作:push でプッシュ、pop でポップ

関数呼び出し命令 call、戻り命令 ret

オペレーティングシステムは各プログラムにスタックを設定し、プログラムの各独立した関数には独立したスタックフレームがあります。

Linux のスタックは高アドレス(スタックトップ)から低アドレス(スタックボトム)に成長します。

多くのアルゴリズム(DFS など)はスタックを利用し、再帰的に実装されます。

呼び出し規約 (Calling Convention)#

呼び出し規約とは#

関数の呼び出しプロセスには 2 つの参加者がいます。一つは呼び出し側 caller、もう一つは呼び出される側 callee

呼び出し規約は、caller と callee がどのように協力して関数呼び出しを実現するかを規定します。具体的には以下の内容が含まれます:

  • 関数の引数をどこに格納するかの問題。レジスタに格納するのか?それともスタックに格納するのか?どのレジスタに格納するのか?スタックのどの位置に格納するのか?
  • 関数の引数をどの順序で渡すかの問題。左から右に引数をスタックに入れるのか、それとも右から左に入れるのか?
  • 戻り値を caller にどのように渡すかの問題。レジスタに格納するのか、他の場所に格納するのか?
  • などなど

では、なぜ呼び出し規約が必要なのでしょうか?

例えば、アセンブリ言語でコードを書く際に統一された規範がなければ、Aは引数をスタックに置くことに慣れており、Bは引数をレジスタに置くことに慣れており、Cは…、それぞれが自分の考えに従ってコードを書いています。こうなると、Aが他の人のコードを呼び出そうとすると、他の人の習慣に従わなければならず、例えばBを呼び出す場合、Aは B が定めたレジスタに引数を置かなければなりません。Cを呼び出す場合は、また別の方法になります…

呼び出し規約は上記の問題を解決するために存在し、関数呼び出しの詳細を規定します。これにより、全員が同じ規約を守り、他の人が書いたコードを呼び出す際に何も変更する必要がなくなります。

関数呼び出しスタック#

  1. 関数呼び出し:関数が呼び出されると、プログラムは呼び出しスタック上に新しいスタックフレームを割り当てます。スタックフレームには関数の引数、局所変数、戻りアドレスなどの情報が含まれます。
  2. 引数の渡し:関数呼び出しの過程で、引数はスタック操作を通じて呼び出される関数に渡されます。これらの引数はスタックフレームに格納され、関数内部で使用されます。
  3. 関数の実行:呼び出された関数が実行を開始し、スタックフレーム内の引数と局所変数を使用します。関数の実行プロセスは複雑なロジックや計算を含む場合があります。
  4. 戻り値の処理:関数の実行が完了すると、プログラムはその関数を呼び出したコードの位置に戻ります。この位置はスタックフレーム内の戻りアドレスによって指定されます。関数に戻り値がある場合、その値は呼び出し側のスタックフレームにプッシュされます。
  5. スタックフレームの破棄:関数呼び出しが完了すると、その対応するスタックフレームは呼び出しスタックからポップされ、破棄され、占有していたメモリリソースが解放されます。

具体的な関数呼び出しの流れ#

  • pop

pop rax の作用:

mov rax [rsp]; // スタックトップのデータをレジスタにポップ

add rsp 8; // スタックトップポインタを 1 単位下げる

  • push

push rax の作用:

sub rsp 8; // スタックフレームを 1 単位上げる

mov [rsp] rax; // レジスタの値をスタックトップに置く

  • jmp

即時ジャンプで、関数呼び出しを伴わず、ループや if-else に使用されます。

例えば call 1234h の作用:

mov rip 1234h;

  • call

関数呼び出しで、戻りアドレスを保存する必要があります。

例えば call 1234h の作用:

push rip;

mov rip 1234h;

  • ret

pop rip

例:main call funB , funB call funA ,スタックフレームの変化を段階的に分析:

関数呼び出しの過程で:

  1. 関数を呼び出す:
    • ripをスタックにプッシュし、戻りアドレスとして保存します。(call)
  2. 呼び出された関数:
    • rbpをスタックにプッシュし、現在のスタックフレームの基準として使用します。
    • rspの値をrbpに代入し、rbpが現在のスタックフレームの底を指すようにします。
    • 局所変数や一時データのためにスタックスペースを割り当て、rspを相応のサイズだけ減少させます。
    • rspを基準ポインタとして使用して、関数の引数や局所変数にアクセスします。

関数が戻るとき:leave;ret;

  1. 呼び出された関数:
    • スタックに割り当てられた局所変数や一時データをポップします。
    • rspを関数呼び出し時の値に戻します。
  2. 呼び出し側の関数:
    • スタックから戻りアドレスをポップします。
    • ripを戻りアドレスに更新します。

スタックフレームの変化の示意図:

+----------------------------+
| main 関数スタックフレーム   |
+----------------------------+
| 戻りアドレス                |
| rbp (main 関数の基準ポインタ) |
+----------------------------+
| funB 呼び出し関数スタックフレーム |
+----------------------------+
| 戻りアドレス                |
| rbp (funB 関数の基準ポインタ) |
+----------------------------+
| funA 呼び出し関数スタックフレーム |
+----------------------------+
| rbp (funA 関数の基準ポインタ) |
| 局所変数                    |
+----------------------------+

引数の渡し方#

関数の戻り値は RAX に渡されます。

x86-64 関数の呼び出し規約は:

  1. 左から右に引数をRDI,RSI,RDX,RCX,R8,R9に順次渡します。

  2. 関数の引数が 6 個を超える場合、右から左にスタックにプッシュして渡します。

システムコール#

syscall 命令#

システム関数を呼び出すために使用され、呼び出し時にシステムコール番号を指定します(64 ビット Linux システムコール表をオンラインで確認できます)。

システムコール番号は RAX レジスタに存在し、引数を設定した後、syscall を実行します。

例:read (0,buf,size) を呼び出す

mov rax 0;
mov rdi 0;
mov rsi  buf;
mov rdx size;
syscall;

ELF ファイル構造#

ELF ファイル形式#

ELF (Executable and Linkable Format) は Linux におけるバイナリ実行可能ファイル形式です。

ELF ファイルヘッダー(ELF Header)#

readelf -h コマンドを使用して ELF ファイルのファイルヘッダーを読み取ることができます。ELF ヘッダーにはプログラムのエントリポイント(Entry Point Address)、セクション情報やセグメント情報が含まれています。ELF ヘッダーのStart of program headersStart of section headersから、セグメントテーブルやセクションテーブルのファイル内の位置を特定できます。

image

セクションヘッダーテーブル(Section Header Table)#

readelf -S コマンドを使用してバイナリ ELF ファイルのセクション情報(sections)を読み取ります。プログラム test には 31 のセクションがあります。アセンブリ言語はセクションに従ってプログラムを記述します。例えば、.text セクション、.data セクション。アセンブリコードと機械コードは一対一で対応しており、アセンブリプログラムがバイナリコードに変換される際にセクション情報が保持されます。

readelf -S test

image

プログラムヘッダーテーブル(Program Header Table)#

ELF プログラムが実行されるとき(メモリにロードされるとき)、ローダー(Loader)はプログラムのセグメントテーブルに基づいてプロセスのメモリイメージ(Image)を作成します。readelf -l コマンドを使用してバイナリ ELF ファイルのセグメント情報(segments)を読み取ります。プログラム test には 13 のセグメントがあり、セグメントの数はセクションの数よりも多いため、複数のセクションが同じセグメントにマッピングされることがあります。

セクションの権限に基づいて:読み書き可能なセクションは 1 つのセグメントにマッピングされ、読み取り専用のセクションは別のセグメントにマッピングされるなどです。

readelf -l test

image

image

リンクビュー / 実行ビュー#

Segment と Section は同じ ELF ファイルを異なる角度から区分けしたものです。これは ELF 内で異なる ** ビュー(View)** と呼ばれます。

Section の観点から見ると ELF ファイルは ** リンクビュー(Linking View)です。
Segment の観点から見るとそれは
実行ビュー(Execution View)** です。
ELF のロードについて話すとき、セグメントは特に Segment を指します。一方、他の状況ではセグメントは Section を指します。

libc#

glibc:GNU C Library、glibc 自体は GNU の C 標準ライブラリであり、後に Linux の標準 C ライブラリとなりました。

その拡張子は libc.so で、本質的には ELF ファイルであり、単独で実行可能です。通常、pwn 問題で接触する動的リンクライブラリは libc.so ファイルです。

Linux のほぼすべてのプログラムは libc に依存しているため、libc 内の関数は非常に重要です。

遅延バインディングメカニズム#

静的コンパイルと動的コンパイル#

動的コンパイルされた実行可能ファイルは、動的リンクライブラリを伴う必要があり、実行時に対応する動的リンクライブラリ内のコマンドを呼び出す必要があります。そのため、利点の一つは実行ファイル自体のサイズを縮小できること、もう一つはコンパイル速度を向上させ、システムリソースを節約できることです。欠点は、たとえ非常に単純なプログラムであっても、リンクライブラリ内の一部のコマンドを使用する場合でも、相対的に大きなリンクライブラリを伴う必要があることです。また、他のコンピュータに対応するランタイムライブラリがインストールされていない場合、動的コンパイルされた実行可能ファイルは実行できません。

静的コンパイルは、コンパイラが実行可能ファイルをコンパイルする際に、必要な動的リンクライブラリ(.so)の一部を抽出し、実行可能ファイルにリンクすることを意味します。これにより、実行可能ファイルは動的リンクライブラリに依存しなくなります。そのため、静的コンパイルと動的コンパイルの実行可能ファイルの利点と欠点は相補的です。

遅延バインディング(Lazy Binding)#

遅延バインディングは、動的リンクの下でプログラムがロードするモジュールに多数の関数呼び出しが含まれているという前提に基づいています。

遅延バインディングは、関数アドレスのバインディングをその関数が初めて呼び出されるときまで遅らせることによって、動的リンカーが大量の関数参照の再配置を処理するのを避けます。

遅延バインディングの実装には、2 つの特別なデータ構造が使用されます:グローバルオフセットテーブル(Global Offset Table、GOT)とプロシージャリンクテーブル(Procedure Linkage Table、PLT)。

グローバルオフセットテーブル GOT#

ライブラリ関数が初めて呼び出された後、プログラムはそのアドレスを got テーブルに保存します。

グローバルオフセットテーブルは ELF ファイル内に独立したセクションとして存在し、2 つのカテゴリを含みます。対応するセクション名は.got.got.pltであり、.gotはすべての外部変数参照のアドレスを保存し、.got.pltはすべての外部関数参照のアドレスを保存します。遅延バインディングには主に.got.pltテーブルが使用されます。.got.pltテーブルの基本構造は以下の図のようになります:

image

ここで、.got.pltの最初の 3 項目は特別なアドレス参照を保存しています:

  • GOT[0].dynamicセクションのアドレスを保存し、動的リンカーはこのアドレスを利用して動的リンクに関連する情報を抽出します。
  • GOT[1]:このモジュールの ID を保存します。
  • GOT[2]:動的リンカーの_dl_runtime_resolve関数へのアドレスを保存します。この関数は共有ライブラリ関数の実際のシンボルアドレスを解決するために使用されます。

プロシージャリンクテーブル PLT#

遅延バインディングを実現するために、外部モジュールの関数を呼び出す際、プログラムは GOT を直接ジャンプするのではなく、PLT テーブルに保存された特定のテーブル項目を介してジャンプします。すべての外部関数に対して、PLT テーブルには対応する項目があり、各テーブル項目には特定の関数を呼び出すための 16 バイトのコードが保存されています。プロシージャリンクテーブルの一般的な構造は以下の通りです:

image

プロシージャリンクテーブルには、コンパイラが呼び出す外部関数のために個別に作成した PLT テーブル項目が含まれるだけでなく、PLT [0] に対応する特別なテーブル項目もあり、これは動的リンカーにジャンプして実際のシンボル解決と再配置作業を行うために使用されます:

image

PLT と GOT#

外部関数を何度呼び出しても、プログラムが実際に呼び出すのは PLT テーブルであり、PLT テーブルは実際には一連のアセンブリ命令で構成されています。

では、なぜ PLT が存在し、過渡的である必要があるのでしょうか?直接 GOT に到達するのではなく?

これは、あなたが多くの親戚を持っている人で、毎週これらの親戚を訪問する必要があると仮定しましょう。あなたはこれらの親戚の住所をすべてノートに記録しておき、訪問する際にそれを見返すということです。このノートが PLT テーブルであり、その中の各住所は対応する GOT テーブルの住所(あなたの親戚の家)にジャンプします。

ある日、あなたは毎日行き来するのが面倒だと感じ、すべての親戚を自宅に招待し、毎週対応する部屋に行くだけで済むようにしました。その場合、ノートは不要になり、捨ててしまいます。これが PLT テーブルが存在する理由の一つであり、メモリをより効率的に利用するためです。

もう一つの理由は、安全性を高めることができるからです。

LINUX のセキュリティ保護メカニズム#

gcc の安全コンパイルオプションの詳細(NX (DEP)、RELRO、PIE (ASLR)、CANARY、FORTIFY)_gcc pie-CSDN ブログ

CANARY#

Canary はスタックオーバーフロー攻撃に対する防護手段であり、その基本原理はメモリのfs: 0x28から先頭バイトが \x00 で長さ 8 バイトのランダム数canaryをコピーすることです。このランダム数はスタックフレームを作成する際にrbpの直後にスタックに入ります(ebp のすぐ上の位置)。攻撃者がバッファオーバーフローを通じて ebp や ebp の下の戻りアドレスを上書きしようとすると、必ず canary の値を上書きすることになります。プログラムが終了する際、プログラムは CANARY の値と以前の値が一致するかどうかを確認し、一致しない場合はそのまま実行を続けず、バッファオーバーフロー攻撃を回避します。

回避方法:

  1. canary を変更する。
  2. canary を漏洩させる。

canary バイパス#

  • フォーマット文字列による canary のバイパス
    • フォーマット文字列を通じて canary の値を読み取る
  • Canary のブルートフォース(fork 関数を持つプログラムに対して)
    • fork の作用は自己複製に相当し、毎回複製されたプログラムはメモリレイアウトが同じであり、canary の値も同じです。したがって、ビットごとにブルートフォース攻撃を行うことができます。プログラムがクラッシュすればそのビットが間違っていることを示し、プログラムが正常に動作すれば次のビットを続けて攻撃できます。正しい canary が得られるまで続けます。
  • スタックの破損(意図的に canary_ssp 漏洩を引き起こす)
  • __stack_chk_fail のハイジャック
    • got テーブル内の__stack_chk_fail 関数のアドレスを変更し、スタックオーバーフロー後にその関数を実行しますが、その関数のアドレスが変更されているため、プログラムは実行したいアドレスにジャンプします。

NX#

スタック上のデータには実行権限がありません(not executable)。これを有効にすると、プログラム内のヒープ、スタック、bss セクションなどの書き込み可能なセクションは実行できなくなります。

回避方法:

mprotect 関数を使用してセクションの権限を変更し、nx 保護は rop や got テーブルのハイジャック利用方法には影響しません。

PIE と ASLR#

ASLR とは?#

ASLR は Linux オペレーティングシステムの機能オプションであり、プログラム(ELF)がメモリにロードされて実行されるときに作用します。これはバッファオーバーフローに対するセキュリティ保護技術であり、ロードアドレスのランダム化を通じて、攻撃者が攻撃コードの位置を直接特定するのを防ぎ、オーバーフロー攻撃を阻止する技術です。

ASLR の有効化と無効化#

現在のシステムの ASLR の状態を確認する:

sudo cat /proc/sys/kernel/randomize_va_space

ASLR には 3 つのセキュリティレベルがあります:

  • 0: ASLR が無効
  • 1:スタック基底アドレス(stack)、共有ライブラリ(.so libraries)、mmap 基底アドレスをランダム化
  • 21に基づいて、ヒープ基底アドレス(chunk)をランダム化

PIE とは?#

PIE は gcc コンパイラの機能オプションであり、プログラム(ELF)のコンパイルプロセスに作用します。これはコードセクション( .text )、データセクション( .data )、未初期化グローバル変数セクション( .bss )などの固定アドレスに対する保護技術です。プログラムが PIE 保護を有効にすると、プログラムが毎回ロードされる際にロードアドレスが変わり、ROPgadget などのツールを使用して問題を解決することができなくなります。

PIE の有効化#

gcc コンパイル時に-fPIEオプションを追加します。

PIE が有効になると、コードセクション( .text )、初期化データセクション( .data )、未初期化データセクション( .bss )のロードアドレスがランダム化されます。

PIE バイパス#

プログラムのロードアドレスは通常、メモリページ単位であるため、プログラムの基底アドレスの最後の 3 桁は必ず 0 になります。つまり、これらのアドレスの既知の最後の 3 桁は実際のアドレスの最後の 3 桁です。このことを知っていれば、PIE を回避するためのアイデアが得られます。完全なアドレスは知らなくても、最後の 3 桁は知っているので、スタック上に既存のアドレスを利用し、それらの最後の 2 桁(最後の 4 桁)だけを変更することができます。

したがって、PIE を回避するための核心的な考え方は ** 部分書き込み(partial writing)** です。

RELRO#

ReLocation Read-Only、スタックアドレスのランダム化は、バイナリデータセクションの保護を強化するための技術です。

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。