- 小さな C の
hello プログラムも Linux では ELF 実行ファイル となり、readelf、nm、objdump で内部構造を直接確認できる
- 実行ファイルを理解するうえでの中核は シンボル、セクション、セグメントであり、それぞれ関数の解決・コード/データの区別・実行時のメモリ配置を担う
objdump と readelf を使えば、.text、.rodata、.data、.bss、.interp といったセクションのバイト列や属性を確認できる
- プログラムは
main から直接始まるのではなく、_start に入った後、複数の初期化処理を経て main を呼び出す
- 実行ファイルは「読めない塊」ではなく定められた形式のファイルなので、ツールを使えばコード・文字列・リンキング情報を段階的に追跡できる
実行ファイルは読めるファイル形式である
- コンパイル済みの実行ファイルは最初は読めない「魔法のようなバイナリ」に見えるが、実際には理解可能な ファイル形式 である
- 例では Linux の ELF バイナリ を対象としており、バイナリはプラットフォーム依存なので説明もそのプラットフォームに結び付いている
- 使用する例は次の C プログラムである
#include <stdio.h>
int main() {
printf("Penguin!\n");
}
gcc -o hello hello.c でコンパイルして hello 実行ファイルを作成し、その内部を見ていく
- 全体の流れは 3 つの概念を中心に進む
- シンボル(symbols):
printf のように別の場所で定義された関数を呼び出す際、その位置を見つけるために使う
- セクション(sections): コードとデータを分ける単位で、
.text、.data、.rodata などがある
- セグメント(segments): セクション群を実行時のメモリ配置単位としてまとめたもの
テキストとして開いても手がかりは見える
cat hello のように実行ファイルをそのまま開くと、ほとんどは文字化けしたように表示される
- それでも出力の中に
Penguin! や ELF のような文字列を見つけられる
- ELF はこのバイナリのファイル形式名である
- 出力の大半を人間が読みにくい理由は、実行ファイルがバイナリデータだからである
シンボルテーブルで関数名と結び付きを確認する
readelf --symbols hello は実行ファイルの シンボルテーブル を出力する
- 例の出力には主要なシンボルが現れる
main: 自分で書いた main() 関数のアドレス
puts@@GLIBC_2.2.5: コード内で呼んだ printf に関連する参照と見られ、コンパイラが最適化で puts に置き換えたと推測できる
_start: プログラム開始に関わる重要なシンボル
- プログラムは
main から直接始まるのではなく、実際には _start に入る
_start は複数の重要な処理を行い、その中には main の呼び出しも含まれる
シンボルはリンクを可能にする
- プログラムに
hello という関数を書くと、コンパイル済みバイナリではその関数コードに hello という シンボル が付く
- ライブラリ関数である
printf を呼ぶには、その関数コードの位置を見つける方法が必要になる
- 関数の位置を解決する過程が リンキング(linking) である
- コンパイル直後に行われるなら静的リンク
- プログラム実行時に行われるなら動的リンク
libc には C 標準ライブラリの関数群が入っている
nm が libc に対して “no symbols” を出力しても、objdump -tT /lib/x86_64-linux-gnu/libc-2.15.so ならシンボルを確認できる
- libc のシンボルテーブルでは、
sprintf、strlen、fork、exec といった関数を確認できる
hello が puts を呼び出し、libc のシンボルテーブルから puts の位置を見つける流れを想像すると、動的リンク の動作が見えてくる
セクションはコードとデータを分ける
objdump -s hello は実行ファイルの各 セクション に入っているバイト列を 16 進数と ASCII で出力する
- 主なセクションは次のとおり
.text: プログラムの実際のコード、つまりアセンブリが入っており、_start と main を含む
.rodata: 読み取り専用データが入り、この例では "Penguin!" という文字列がある
.interp: 動的リンカのファイル名が入る
- セクションとセグメントでは使われる時点が異なる
- セクション はリンク時に
ld が使う
- セグメント は実行時に使われる
readelf --sections hello でセクションのメタデータをより詳しく見られる
- 例のフラグを見ると各セクションの性質が分かる
.text: 実行可能で読み取り専用
.rodata: 読み取り専用
.data: 読み書き可能
.bss: 書き込み可能なデータ領域
逆アセンブルで機械語をアセンブリとして見る
.text セクションには、CPU がコードとして解釈して実行するバイト列が入っている
- 例の
.text 先頭バイト 31 ed は人間にはすぐ意味が分からないため、逆アセンブラ が必要になる
objdump -d ./hello は .text セクションを逆アセンブルし、アセンブリ命令として表示する
- 例の出力では
31 ed は xor %ebp,%ebp と表示される
- この方法で、バイナリ内のコードバイトがどのアセンブリ命令に対応するか確認できる
セグメントは実行時のメモリ配置を決める
- 実行ファイルは セグメント、またはプログラムヘッダ(program headers)でも構成される
readelf --segments hello はプログラムのセグメントと、セクション-セグメントの対応付けを表示する
- セグメントはプログラム各部をメモリ上でどう分けて配置するかを決めるために使われる
- 例には 2 つの主要な
LOAD セグメントがある
- 1 つ目の
LOAD: R E と表示され、読み取りと実行が可能
- 2 つ目の
LOAD: RW と表示され、読み取りと書き込みが可能
.text は読み取りと実行が必要だが書き込みは不要なので、1 つ目のセグメントに入る
.data と .bss は書き込み可能である必要があるが実行する必要はないため、2 つ目のセグメントに入る
さらに見るためのツールと資料
- ELF 実行ファイルは特別な魔法ではなく一般的なファイル形式であり、Linux バイナリは
readelf、nm、objdump で調べられる
- 関連資料
1件のコメント
Hacker News のコメント
別スレッドでも述べたように https://news.ycombinator.com/item?id=38847750#38862450、ELF を手で一度書いてみることを強くおすすめします
実行ファイルの基本構成要素を理解するよい練習になりますし、この記事とは逆に上から下ではなく下から上へアプローチしたい場合にも役立ちます
その別の HN 投稿の複数のスレッドにも、よい議論がたくさんあります
形式を自分や他の人に説明するために、ファイルのバイト列を見せるインタラクティブな可視化も作りました
バイトをクリックすると説明が表示され、ファイル内の関連するバイトが強調表示されて理解の助けになります: https://scratchpad.avikdas.com/elf-explanation/elf-explanati...
動的リンクにはかなりの実装上の複雑さがありますが、静的 ELF だけをサポートするならかなり直感的です
最初は動作しないときのデバッグが実質的に不可能でイライラしますが、ついに動き始めると本当に素晴らしいです
ELF は興味深い方法でパッチできますし、補助ベクタを使えば実行中の自己観察も可能です
Linux がプログラムヘッダテーブルのアドレスを渡してくれるので、そこからどこへでも到達できますし、LOAD セグメントをバイナリ全体を覆うように拡張すればよいのです
例えば、私の Lisp インタプリタ実行ファイルの中に Lisp モジュールとコードを直接挿入するツールを作りました
挿入されたセグメントは ELF で自動的にロードされ、インタプリタがそれを見つけて実行します
この小さな機能がとても気に入ったので記事も書きました: https://www.matheusmoreira.com/articles/self-contained-lone-...
主流の言語もこういう方式を採用してくれるとよいのですが
ドキュメントを読み、プロセッサのデータシートから必要なバイトを選び、それを複数のセクションに順番に配置し、ELF フィールドを埋めると、結局はすべて入力する作業に行き着きます
ELF 以前の時代の 8 ビット Apple II のような環境では、機械語モニタがプログラムのバイト列を直接入力させてくれ、そのバイト列が実行されました
ディスクに保存するのは少し複雑になるだけで、そこにもまた機会があります
ディスクセクタエディタでファイルを作ることもでき、そうやって続いていきます
私の理解では、
mainシンボルはC に特有のものです_startシンボルは言語に依存しないバイナリのエントリポイントで、この場合はmainを呼び出しますエントリポイントを
_startと呼び、そこにmainのargc/argvを渡す、といった慣例があったなら、形式の柔軟性ははるかに低くなっていたでしょう_startという名前も特別ではありませんバイナリはヘッダにエントリポイントのアドレスを書いておき、オペレーティングシステムはそのアドレスから実行を開始します
そのシンボルを
_startと呼ぶのは C や他の言語の慣例にすぎず、リンカが ELF ヘッダを書く際にエントリポイントを設定するために使います自分でリンカスクリプトを書くなら、エントリポイント名は好きなように付けられます
mainシンボルは、指摘のとおり hosted C でのみ提供されますfreestanding C では任意のエントリポイントを持てます
_startも単にリンカのデフォルト値にすぎず、-Wl,--entry="${symbol}"でよりよいシンボルを指定できますし、GCC は見苦しい-Wlなしで直接設定することもサポートしていますまた、エントリポイントは実際にはシンボルではなくポインタです
リンカが指定されたシンボルのアドレスを取得し、それを ELF のエントリポイントとして設定しているだけです
引数の個数と引数ベクタだけでなく、スタックには環境ベクタと補助ベクタも入っています
プロセス開始コードは、これらの値をスタックから取り出して適切なレジスタに入れ、任意の C 関数を呼び出す程度の単純なものにできます
エントリポイント自体は関数ではないので、戻る先がありません
エントリポイントのコードは、
mainがステータスコードを返したときにプロセスがきれいに終了するよう、exitシステムコールで終わる必要があります少なくとも Linux ではこのように動作します
Rust/C/C++ のような言語では、リンカフラグを通じて初期化する変数を注入することもできます
プログラムが動的リンクされている場合、
_startの前にリンカランタイムが実行され、リンクを解決してから制御を_startに渡すのだと理解しています結局のところ、拡張性を提供するために有機的に積み重ねられたハックの上のハックであり、社会的に十分受け入れられ、十分うまく動いているので使い続けている、ということです
2012年に学問上の進路を数学からコンピューターサイエンスへ移したときにブログを始めたのですが、このテーマは文字どおり最初に勉強した内容でした: https://heinrichhartmann.com/archive/Dissecting-Hello-World....
この深いウサギ穴に入り込んだことを後悔したことはありません
記憶が正しければ、Juliaにも数学のバックグラウンドがあります
もしかすると数学系の人たちがこうした実験に引き寄せられるのは、底から推論したいという欲求のせいかもしれません
彼女がこの内容を多くの人にとって近づきやすいものにしてくれてうれしいです
Juliaの記事はいつも素晴らしいです
コンパイル済みコードは秘密を隠せないのだと教えるとき、
stringsを実演するといつも効果的でしたある気の毒な人が、バイナリに対して
stringsを実行したのと同じような方法でパスワードを見つけたという理由で罰金を科されました裁判官たちは、彼がソフトウェアのセキュリティ対策を「回避」したと見なしました: https://www.theregister.com/2024/01/19/germany_fine_security...
批判でも揚げ足取りでもなく、ただ思い浮かんだことです
「バイナリはプラットフォーム固有の定義に近いので、この内容もすべてプラットフォーム固有だ」という文を見て、Actually Portable Executableが同じバイナリを複数のプラットフォームで実行できることを示したときのことを思い出しました
いまだにあのシュールな瞬間から精神的に完全には立ち直れていません
何十年ものあいだ、Javaやクロスプラットフォームライブラリなど、フラクタルのようなあらゆる方法でクロスプラットフォーム問題を解こうとしてきたのに、解決策はずっと目の前にあったわけです
高速なコンピューターの時代には、ソース配布とローカルコンパイルのほうがバイナリ配布より良いと思います
残念ながら、私たちが依存しているソフトウェアの多くは大きすぎ、コンパイラは相対的に遅いため、バイナリ配布は一種の必要悪になっています
ポータブルなバイナリよりも、自然に速くコンパイルできるより単純なソフトウェア構成要素と、より高速なコンパイラにもっと労力が注がれるとよいと思います
どのシステムでも実行できるスクリプトで、そのスクリプトがバイナリをロードできるものです
記憶が正しければ、元のバージョンはロード前にbase64からデコードする必要がありました
つまり、実行可能なバイナリローダーに近いものです
1990年代初頭に実行ファイル形式に魅了され、数週間かけてModula 2でDOSとWindowsの実行ファイルビューアを作り、VEXEと名付けて1991年にシェアウェアとして配布しました
このツールはクラッカーの間でニッチな人気を得て、+ORCのチュートリアルにも言及されるほどでした: https://gist.github.com/callowaysutton/48bdf0245e17e72d41a15...
おそらく、プログラムのリバースエンジニアリングを防ぐために使われたさまざまな暗号化方式や圧縮方式を検出できたからでしょう
ELFバイナリファイルがどれほど小さくなれるのか気になるなら、この面白い記事が気に入るかもしれません: https://www.muppetlabs.com/~breadbox/software/tiny/teensy.ht...
ELFをSQLで探索できるようにする私のツールも見てみるとよいでしょう: https://github.com/fzakaria/sqlelf
Pythonのバックグラウンドが強い人向けに、実用的な低レベルプログラミングの入門資料や本を勧めてもらえますか
最近Rustを学び始めたのですが、追いつくべきことが多いと気づきました
コンパイラの授業を受けたことがないので、多くの情報を見落としているのかもしれません
たとえば、バイナリにシンボルというものがあることすら知りませんでしたし、ELFとMach-Oの違いも知りませんでした
バイナリをターミナルに
catするのは悲しみへの近道です私は
| hdが好きですが、実質的にはhexdump -Cで、肉眼で見るには同じくらい難解ではあります