ELFファイル形式

このページの目次

ELFは異なる段階で機能する。コンパイラは再配置可能なオブジェクトファイルを生成し、リンカはコードとデータを結合し、カーネルやダイナミックローダはプロセスイメージを構築する。デバッガはシンボルやデバッグ情報も読み込む。これらは、順番に実行される複数のレイヤーではなく、同じファイルの異なるインデックスにアクセスしている。

ELFを理解する際は、まず3つの質問に答えよ。その値はファイルオフセットか仮想アドレスか。そのテーブルはどの段階で使われるか。その内容は本当にプロセスにマップされる必要があるか。

2つのビュー、包含チェーンではない

同じELF:sectionが内容を整理し、segmentがロード要件を記述 リンク時ビュー:section ロード時ビュー:プログラムヘッダ .text PT_LOAD:R + X .rodata、.dynsym、.dynstr PT_LOAD:R .data、.bss、.dynamic PT_LOAD:R + W 図のレイアウトは固定テンプレートではありません。PT_DYNAMICはダイナミックエントリを指し、エントリがシンボルテーブルなどのデータを参照します。

Sectionはコード、データ、シンボル、リロケーションなどの用途に応じて内容を整理します。Program headerはマップやその他の実行時の要件を記述し、その中でPT_LOADのみがロード可能なセグメントを示します。複数のsectionが1つのPT_LOADでカバーされることもあります。PT_DYNAMICやPT_INTERPなどは、すでにPT_LOADの範囲内にあるデータを記述している場合があり、これらのエントリを互いに重ならないファイルの区切りとして扱うことはできません。

.dynsymは「PT_DYNAMICに属する」わけではありません。PT_DYNAMICはダイナミックエントリ配列を指し、その中のDT_SYMTABがダイナミックシンボルテーブルを指します。実行時の解決は.dynsymというsection名に依存しません。読み取り専用データはコードと同じ実行可能セグメントに共有する必要はなく、実際の権限レイアウトはリンカオプションによって決定されます。ELFダイナミックリンク構造

ELFヘッダはインデックスのエントリポイント

ファイル先頭のe_identは、後の構造をどのように解釈すべきかをまず示します。

バイト/フィールド意味
0–30x7f、E、L、F のマジックナンバー
EI_CLASS、バイト4ELF32またはELF64。構造レイアウトを決定
EI_DATA、バイト5データのバイトオーダー
EI_VERSION、バイト6ELFフォーマットのバージョン
EI_OSABI、バイト7OS/ABIの識別子
EI_ABIVERSION、バイト8対応するABIのバージョン。パディングバイトではない
後の予約バイト別のランダムなフィールドの組み合わせとして解釈すべきではない

残りのヘッダでは、e_machineはターゲットアーキテクチャを記述し、e_entryはエントリポイントの仮想アドレスを記述します。e_phoff/e_shoffは2つのテーブルのファイルオフセットです。エントリの長さと数はそれぞれe_*entsize、e_*numで記述されます。ホストマシンのポインタサイズからターゲットファイルのレイアウトを推測してはいけません。elf(5)

e_type一般的な用途直接導き出せない結論
ET_RELコンパイルされた.o通常、直接実行はできない
ET_EXEC通常はPIEではない実行可能イメージプロセス内の全アドレスが固定されているわけではない
ET_DYN共有オブジェクト。PIEにも使用されるASLRが有効になっている保証はなく、単独でメインプログラムであることを証明するものでもない
ET_COREプロセスダンプ全メモリと完全なデバッグ情報が含まれている保証はない

PIEがデフォルトで有効かどうかは、ツールチェーンやディストリビューションの設定に依存します。明示的に-fPIE -pieを使用すると、教育実験でより扱いやすくなります。結果を確認する際は、プログラムヘッダ、ダイナミックフラグ、ツールの解釈を組み合わせ、拡張子だけを見てはいけません。

PT_LOAD:ファイル内容がメモリにどのように対応するか

ロードオフセットをBとし、1つのPT_LOADの主要なフィールドは以下の通りです。p_offset(ファイル上の起点)、p_vaddr(リンク時の視点での仮想起点)、p_filesz(ファイル内容の長さ)、p_memsz(メモリ上の長さ)、p_flags(読み書き実行の権限)、そしてp_align(アラインメントの制約)。

このセグメントのファイル上でサポートされる領域において、ファイルオフセットfに対応する実行時のアドレスは次の式で表されます。

runtime_address = B + p_vaddr + (f - p_offset)

これは、fがそのセグメントのファイル上の範囲内にあることを要求します。p_memszはp_filesz以上でなければならず、超える分のメモリ領域はゼロで初期化されます。p_alignが1より大きい場合は、対応する2のべき乗のアラインメント値でなければならず、p_vaddrとp_offsetは合同条件を満たし、ロードにはページアラインメントの規則も満たす必要があります。ELF Program Loading

例えば、読み書き可能なセグメントでp_offset=0x3000、p_vaddr=0x5000、p_filesz=0x600、p_memsz=0xa00の場合、ファイルの[0x3000, 0x3600)はメモリ[B+0x5000, B+0x5600)に対応し、続く[B+0x5600, B+0x5a00)はゼロになります。ファイルオフセット0x3120はB+0x5120に対応します。

実際のマップはページ単位で行われ、ファイル末尾のページのゼロ埋めと、その後の匿名ページは異なる手順が必要になる場合があります。p_memszをそのままファイルマップの長さとして扱い、ファイル末尾以降が自然にELFのゼロ埋めセマンティクスに従うと考えるのは誤りです。

同じセグメント内でも、前半分のみがファイルオフセットに変換可能 ファイルオフセット 0x3000 ≤ f < 0x3600 0x600 バイト:ファイルから取得 リンク時の仮想アドレス 0x5000 ≤ v < 0x5600 ファイルでサポートされる初期化済みコンテンツ 0x5600 ≤ v < 0x5a00 0x400 バイト:ゼロで初期化 実行時アドレス = B + v。右側のゼロ埋め領域には対応するファイルバイトがありません。 区間の右端は含まれない:0x5600はすでにゼロ埋め領域に入り、0x5a00はこのセグメントから外れている。

アドレスを逆引きする際は、まずそれがどの領域に属するかを判断する

デバッガが実行時アドレスrを与え、それがこのELFイメージに属していることが分かっていると仮定します。まず、そのイメージのロードオフセットBを引いて、リンク時の仮想アドレスvを取得します。次に、vをカバーするPT_LOADを検索し、最後にファイルオフセットへの変換を試みます。同じプロセス内のメインプログラムと異なる共有ライブラリはそれぞれ独自のロードオフセットを持つため、メインプログラムのBを使ってlibc内のアドレスを解釈することはできません。

以下では、前述の単一セグメントのアドレス分類のみを計算し、ELFを読み込んだりページテーブルをシミュレートしたりはしません。半開区間を使うことで、最も間違いやすい2つの境界を直接チェックできます。

OFFSET, VADDR = 0x3000, 0x5000
FILESZ, MEMSZ = 0x600, 0xa00

def locate(v):
    delta = v - VADDR
    if not 0 <= delta < MEMSZ:
        return ("outside", None)
    if delta >= FILESZ:
        return ("zero", None)
    return ("file", OFFSET + delta)

assert locate(0x4fff) == ("outside", None)
assert locate(0x5000) == ("file", 0x3000)
assert locate(0x55ff) == ("file", 0x35ff)
assert locate(0x5600) == ("zero", None)
assert locate(0x59ff) == ("zero", None)
assert locate(0x5a00) == ("outside", None)
bias = 0x70000000
runtime = bias + 0x5120
assert locate(runtime - bias) == ("file", 0x3120)

vが0x5800の場合、機械的に式に代入するとファイルオフセット0x3800が得られますが、その位置はこのアドレスの初期データのソースではありません。そこに他のファイルコンテンツが存在したとしても、そのゼロ初期化されたメモリを説明するために使用することはできません。逆に、file分類はイメージの初期ソースを記述しているだけです。リロケーションやプログラムによる書き込み後、実行時のバイトはディスク上の内容と一致しない場合があります。

1つのアドレス、3つの座標

重要なのは、まず式に代入することではなく、Δがどの区間に属するかをチェックすることです。2つの帯はセグメント内の位置目盛りを共有しています。上のファイル帯は0x600で終わり、下のメモリ帯は0xa00まで続きます。この境界を越えると、実行アドレスは存在しますが、ファイルのソースはすでに消えています。Bを変えても、ソースの分類は変わりません。

図を準備しています
1つのアドレス、3つの座標

アドレスの変換はファイルソースを持つ領域にのみ適用されます。ゼロ埋めされた尾部には対応するファイルバイトがありません。ロードオフセットは実行アドレスを単にシフトさせるだけです。

一般的なsectionの役割による速查

役割一般的な名前読む重点
プログラムの内容.text、.rodata、.dataSHF_ALLOCフラグの有無、および最終的なセグメントの権限
ゼロ初期化ストレージ.bss、SHT_NOBITSメモリ上のサイズはあるが、ファイル内には同じサイズの有効なペイロードがない
シンボル.symtab、.dynsym通常シンボルテーブルと実行時ダイナミックシンボルテーブルの役割分担が異なる
名前.strtab、.dynstr、.shstrtab文字列のオフセットは正しい文字列テーブルを指している必要がある
リロケーション.rel.、.rela.RELとRELAでのaddendの格納方法が異なる
ダイナミック情報.dynamic、.interp依存関係、シンボルアドレス、インタプリタのパスなど
初期化.init_array、.fini_array通常は関数ポインタを格納し、.init/.finiの命令内容とは異なる
デバッグ.debug_*通常はロードされず、個別のデバッグファイルに分割されている場合がある

sh_nameはsection名文字列テーブル内のオフセットであり、ポインタではありません。sh_link、sh_infoの解釈はsectionのタイプに依存します。例えば、リロケーションテーブルはシンボルテーブルやターゲットsectionと関連付ける必要があり、それらにグローバルな固定の意味を押し付けてはいけません。

sectionヘッダテーブルのない実行可能ELFでも、ロード要件を満たせば実行できる可能性があります。これは、任意のバイトを削除しても安全という意味ではありません。stripは通常、一部のシンボルやデバッグ情報を削除しますが、ダイナミックリロケーションでまだ使用される内容は保持する必要があります。デバッグ時には一致する個別のデバッグファイルに頼ることもでき、「strip後はcoreファイルは永遠に分析できない」と単純に言うことはできません。

自分のプログラムで2つのテーブルを照合する

#include <stdio.h>
int initialized = 7;
unsigned char zeros[8192];
int main(void)
{
    printf("%d %u\n", initialized, (unsigned)zeros[8191]);
    return 0;
}
cc -g -fPIE -pie elf-demo.c -o elf-demo
./elf-demo
readelf -hW elf-demo
readelf -SW elf-demo
readelf -lW elf-demo
readelf -dW elf-demo
readelf -rW elf-demo
readelf --dyn-syms -W elf-demo

出力は7と0になるはずです。まず、-SWの出力で.data/.bssを見つけ、次に-lWの末尾にあるSection to Segment mappingを確認し、最後に該当するLOADのFileSizとMemSizを比較します。ちょうど2つのLOADがあるとは限らないと想定せず、他のマシンのアドレスをそのままコピーしないでください。

さらに、同じソースコードを3つの異なる段階で止める

これらのオプションをサポートするLinux GCC/Clangツールチェーンでは、以下のビルドを続けて行います。ここでは明示的にPIEと非PIEを指定し、ディストリビューションのデフォルト値が比較に影響しないようにします。

cc -g -fPIE -c elf-demo.c -o elf-demo.o
cc -g -fno-pie -no-pie elf-demo.c -o elf-demo-exec
./elf-demo-exec
readelf -hW elf-demo.o
readelf -lW elf-demo.o
readelf -sW elf-demo.o
readelf -hW elf-demo-exec
readelf -sW elf-demo
観察点elf-demo.oelf-demo(PIE)elf-demo-exec
e_typeRELDYNEXEC
プログラムヘッダこの通常のコンパイル成果物には存在しないロード可能イメージを記述ロード可能イメージを記述
printfシンボル未定義。リンク待ち通常、共有ライブラリによって提供される通常、共有ライブラリによって提供される
initializedの通常定義済みシンボル値所属するsectionへの相対オフセットリンク時の視点での仮想アドレスリンク時の視点での仮想アドレス
直接実行可能か通常のプログラムとして起動できない可能。7 0を出力可能。7 0を出力

ET_EXECでもダイナミックリンクできるため、「非PIE」と「静的リンク」は別の問題です。initializedを比較する際は、Ndxと一緒に読み取ってください。ET_RELでのValue=0は、単にその変数が.dataの先頭にあるだけかもしれません。未定義のprintfのValue=0は、関数がアドレス0にあることを意味するものではありません。TLSシンボルには専用のオフセットの意味があり、すべてのst_valueを単にBに足すことはできません。ELFシンボル値

BSSのサイズがファイルにそのまま追加されない理由

zerosは8192バイトありますが、その初期コンテンツは「サイズとゼロクリアの規則」で表現できるため、ファイル内に8192個のゼロを保存する必要はありません。readelf -SWで.bssのNOBITSタイプとSizeを確認し、readelf -lWで書き込み可能なLOADのMemSizとFileSizを確認してください。差額には、アラインメントや他のゼロ初期化オブジェクトも含まれる可能性があるため、それがちょうど8192に一致する必要はありません。

ファイルの総サイズには、ELFテーブル、デバッグ情報、シンボル、パディングなども含まれるため、すべてのLOADのFileSizを単純に足してファイルの長さとすることはできません。セグメントの範囲自体も、排他的な区切りであることが保証されているわけではありません。同様に、MemSizはイメージの仮想メモリ上のニーズを表しており、プロセスが現在保持している物理ページ数を直接示すものではありません。これらの量を理解した上で、「ディスク上のファイルサイズが大きい」「アドレス空間が大きい」「現在の物理メモリ使用量が大きい」を区別できるようになりましょう。

パーサを書く際は、まず構造の境界を守る

ファイルバッファを直接Elf64_Ehdr*にキャストすると、バイトオーダー、アラインメント、切り捨て、ELF32の処理を無視することになります。信頼性の高いパーサでは、まずe_identを検証し、ターゲットのエンコーディングに従ってフィールドを読み取り、オフセットと長さがすべてファイルの範囲内にあるかを確認する必要があります。乗算と加算のオーバーフローチェックは、アクセスの前に発生させる必要があります。

数量フィールドには拡張エンコーディングが存在します。e_phnumのPN_XNUM、e_shnumの拡張値、およびSHN_XINDEXは、section 0と組み合わせてデコードする必要があります。文字列テーブルについても、オフセットと終了境界を検証する必要があります。教育現場で「64バイトを読み取っていくつかのフィールドを出力する」のは、フォーマット識別に過ぎず、完全なELFバリデーターとは呼びません。

文字列のオフセットも、それ自体のテーブル内で解釈する必要がある

例えば、文字列テーブルがb"\0.text\0.data\0"の場合、オフセット1は.textに対応し、オフセット7は.dataに対応します。オフセットは既存の文字列の途中を指すこともでき、オフセット2はtextに対応します。これまでにスキャンした文字列の先頭のみを受け入れるわけではありません。読み取りは、この1つのテーブルの境界内でNULを見つける必要があり、後ろのsectionにまでスキャンしてはいけません。ELF String Table

def read_name(table, offset):
    if not 0 <= offset < len(table):
        raise ValueError("string offset outside table")
    end = table.find(b"\0", offset)
    if end == -1:
        raise ValueError("unterminated name")
    return table[offset:end]  # 元のバイトを保持し、名前が必ずしもUTF-8であると仮定しない

table = b"\0.text\0.data\0"
assert read_name(table, 0) == b""
assert read_name(table, 1) == b".text"
assert read_name(table, 2) == b"text"
assert read_name(table, 7) == b".data"
for data, offset in [(table, -1), (table, len(table)), (b"\0bad", 1)]:
    try:
        read_name(data, offset)
    except ValueError:
        pass
    else:
        raise AssertionError("invalid name accepted")

この関数は単なる1回の名前読み取りを担当するだけです。ファイルレベルの解析では、文字列テーブル全体の位置、長さ、構造ルールを検証する必要があります。section名はe_shstrndxで指定されたテーブルを使用し、シンボル名は対応するシンボルテーブルに関連付けられた文字列テーブルを使用します。オフセットの数値が同じだからといって、名前が同じであるとは限りません。

次に、ダイナミックリンクを使って、シンボルがどのようにアドレスになるかを理解し、ロードと起動を使って、これらのアドレスがいつ使用されるかを追跡します。