アセンブリの基礎#
習得度:アセンブリを理解し、ガジェットを分析し、ステップデバッグでレジスタの状態を理解する
参考記事:x86_64 アセンブリの一つ:AT&T アセンブリ構文_x86_64 アセンブリ at&t-CSDN ブログ
レジスタ#
一般的な x86 CPU
レジスタは 8 つあります:EAX
、EBX
、ECX
、EDX
、EDI
、ESI
、EBP
、ESP
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)#
アセンブリ言語命令#
一般的なアセンブリ命令:mov
、je
、jmp
、call
、add
、sub
、inc
、dec
、and
、or
データ転送命令#
命令 | 名称 | 例 | 備考 |
---|---|---|---|
MOV | 転送命令 | MOV dest, src | データを src から dest に移動 |
PUSH | プッシュ命令 | PUSH src | ソースオペランド src をスタックにプッシュ |
POP | ポップ命令 | POP dest | スタックのトップからデータを dest にポップ |
算術演算命令#
命令 | 名称 | 例 | 備考 |
---|---|---|---|
ADD | 加算命令 | ADD dest, src | dest に src を加算 |
SUB | 減算命令 | SUB dest, src | dest から src を減算 |
INC | インクリメント命令 | INC dest | dest に 1 を加算 |
DEC | デクリメント命令 | DEC dest | dest から 1 を減算 |
論理演算命令#
命令 | 名称 | 例 | 備考 |
---|---|---|---|
NOT | 否定命令 | NOT dest | オペランド dest をビット単位で否定 |
AND | 論理積命令 | AND dest, src | dest と src の論理積を dest に戻す |
OR | 論理和命令 | OR dest, src | dest と src の論理和を dest に戻す |
XOR | 排他的論理和命令 | XOR dest, src | dest と src の排他的論理和を dest に戻す |
ループ制御命令#
命令 | 名称 | 例 | 備考 |
---|---|---|---|
LOOP | カウントループ命令 | LOOP label | ECX の値を 1 減らし、ECX の値が 0 でない場合は label にジャンプ、そうでなければ LOOP の後の文を実行 |
転送命令#
命令 | 名称 | 例 | 備考 |
---|---|---|---|
JMP | 無条件転送命令 | JMP label | 無条件で label に転送 |
CALL | プロシージャ呼び出し命令 | CALL label | label を直接呼び出す |
JE | 条件転送命令 | JE label | zf =1 の時に label にジャンプ |
JNE | 条件転送命令 | JNE label | zf=0 の時に label にジャンプ |
Linux と Windows のアセンブリの違い#
linux
と windows
のアセンブリ構文は異なります。実際、2 つの構文の違いはシステムの違いとは絶対的な関係はなく、一般的に linux
では gcc/g++
コンパイラが使用され、windows
ではマイクロソフトの cl
、つまり MSBUILD
が使用されます。そのため、異なるコードが生成されるのはコンパイラの違いによるもので、gcc
では AT&T のアセンブリ構文形式が採用され、MSBUILD
では Intel アセンブリ構文形式が採用されています。
差異 | Intel | AT&T |
---|---|---|
レジスタ名の参照 | eax | %eax |
値の代入順序 | mov dest, src | movl src, dest |
レジスタ、即値命令の接頭辞 | mov ebx, 0xd00d | movl $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
を呼び出す場合は、また別の方法になります…
呼び出し規約は上記の問題を解決するために存在し、関数呼び出しの詳細を規定します。これにより、全員が同じ規約を守り、他の人が書いたコードを呼び出す際に何も変更する必要がなくなります。
関数呼び出しスタック#
- 関数呼び出し:関数が呼び出されると、プログラムは呼び出しスタック上に新しいスタックフレームを割り当てます。スタックフレームには関数の引数、局所変数、戻りアドレスなどの情報が含まれます。
- 引数の渡し:関数呼び出しの過程で、引数はスタック操作を通じて呼び出される関数に渡されます。これらの引数はスタックフレームに格納され、関数内部で使用されます。
- 関数の実行:呼び出された関数が実行を開始し、スタックフレーム内の引数と局所変数を使用します。関数の実行プロセスは複雑なロジックや計算を含む場合があります。
- 戻り値の処理:関数の実行が完了すると、プログラムはその関数を呼び出したコードの位置に戻ります。この位置はスタックフレーム内の戻りアドレスによって指定されます。関数に戻り値がある場合、その値は呼び出し側のスタックフレームにプッシュされます。
- スタックフレームの破棄:関数呼び出しが完了すると、その対応するスタックフレームは呼び出しスタックからポップされ、破棄され、占有していたメモリリソースが解放されます。
具体的な関数呼び出しの流れ#
- 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 ,スタックフレームの変化を段階的に分析:
関数呼び出しの過程で:
- 関数を呼び出す:
rip
をスタックにプッシュし、戻りアドレスとして保存します。(call)
- 呼び出された関数:
rbp
をスタックにプッシュし、現在のスタックフレームの基準として使用します。rsp
の値をrbp
に代入し、rbp
が現在のスタックフレームの底を指すようにします。- 局所変数や一時データのためにスタックスペースを割り当て、
rsp
を相応のサイズだけ減少させます。 rsp
を基準ポインタとして使用して、関数の引数や局所変数にアクセスします。
関数が戻るとき:leave;ret;
- 呼び出された関数:
- スタックに割り当てられた局所変数や一時データをポップします。
rsp
を関数呼び出し時の値に戻します。
- 呼び出し側の関数:
- スタックから戻りアドレスをポップします。
rip
を戻りアドレスに更新します。
スタックフレームの変化の示意図:
+----------------------------+
| main 関数スタックフレーム |
+----------------------------+
| 戻りアドレス |
| rbp (main 関数の基準ポインタ) |
+----------------------------+
| funB 呼び出し関数スタックフレーム |
+----------------------------+
| 戻りアドレス |
| rbp (funB 関数の基準ポインタ) |
+----------------------------+
| funA 呼び出し関数スタックフレーム |
+----------------------------+
| rbp (funA 関数の基準ポインタ) |
| 局所変数 |
+----------------------------+
引数の渡し方#
関数の戻り値は RAX に渡されます。
x86-64 関数の呼び出し規約は:
-
左から右に引数を
RDI
,RSI
,RDX
,RCX
,R8
,R9
に順次渡します。 -
関数の引数が 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 headersとStart of section headersから、セグメントテーブルやセクションテーブルのファイル内の位置を特定できます。
セクションヘッダーテーブル(Section Header Table)#
readelf -S コマンドを使用してバイナリ ELF ファイルのセクション情報(sections)を読み取ります。プログラム test には 31 のセクションがあります。アセンブリ言語はセクションに従ってプログラムを記述します。例えば、.text セクション、.data セクション。アセンブリコードと機械コードは一対一で対応しており、アセンブリプログラムがバイナリコードに変換される際にセクション情報が保持されます。
readelf -S test
プログラムヘッダーテーブル(Program Header Table)#
ELF プログラムが実行されるとき(メモリにロードされるとき)、ローダー(Loader)はプログラムのセグメントテーブルに基づいてプロセスのメモリイメージ(Image)を作成します。readelf -l コマンドを使用してバイナリ ELF ファイルのセグメント情報(segments)を読み取ります。プログラム test には 13 のセグメントがあり、セグメントの数はセクションの数よりも多いため、複数のセクションが同じセグメントにマッピングされることがあります。
セクションの権限に基づいて:読み書き可能なセクションは 1 つのセグメントにマッピングされ、読み取り専用のセクションは別のセグメントにマッピングされるなどです。
readelf -l test
リンクビュー / 実行ビュー#
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
テーブルの基本構造は以下の図のようになります:
ここで、.got.plt
の最初の 3 項目は特別なアドレス参照を保存しています:
- GOT[0]:
.dynamic
セクションのアドレスを保存し、動的リンカーはこのアドレスを利用して動的リンクに関連する情報を抽出します。 - GOT[1]:このモジュールの ID を保存します。
- GOT[2]:動的リンカーの
_dl_runtime_resolve
関数へのアドレスを保存します。この関数は共有ライブラリ関数の実際のシンボルアドレスを解決するために使用されます。
プロシージャリンクテーブル PLT#
遅延バインディングを実現するために、外部モジュールの関数を呼び出す際、プログラムは GOT を直接ジャンプするのではなく、PLT テーブルに保存された特定のテーブル項目を介してジャンプします。すべての外部関数に対して、PLT テーブルには対応する項目があり、各テーブル項目には特定の関数を呼び出すための 16 バイトのコードが保存されています。プロシージャリンクテーブルの一般的な構造は以下の通りです:
プロシージャリンクテーブルには、コンパイラが呼び出す外部関数のために個別に作成した PLT テーブル項目が含まれるだけでなく、PLT [0] に対応する特別なテーブル項目もあり、これは動的リンカーにジャンプして実際のシンボル解決と再配置作業を行うために使用されます:
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 の値と以前の値が一致するかどうかを確認し、一致しない場合はそのまま実行を続けず、バッファオーバーフロー攻撃を回避します。
回避方法:
- canary を変更する。
- 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 基底アドレスをランダム化
- 2:1に基づいて、ヒープ基底アドレス(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、スタックアドレスのランダム化は、バイナリデータセクションの保護を強化するための技術です。