6 ポイント 投稿者 GN⁺ 2024-02-03 | 1件のコメント | WhatsAppで共有
  • 小さな C の hello プログラムも Linux では ELF 実行ファイル となり、readelfnmobjdump で内部構造を直接確認できる
  • 実行ファイルを理解するうえでの中核は シンボル、セクション、セグメントであり、それぞれ関数の解決・コード/データの区別・実行時のメモリ配置を担う
  • objdumpreadelf を使えば、.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 のシンボルテーブルでは、sprintfstrlenforkexec といった関数を確認できる
  • helloputs を呼び出し、libc のシンボルテーブルから puts の位置を見つける流れを想像すると、動的リンク の動作が見えてくる

セクションはコードとデータを分ける

  • objdump -s hello は実行ファイルの各 セクション に入っているバイト列を 16 進数と ASCII で出力する
  • 主なセクションは次のとおり
    • .text: プログラムの実際のコード、つまりアセンブリが入っており、_startmain を含む
    • .rodata: 読み取り専用データが入り、この例では "Penguin!" という文字列がある
    • .interp: 動的リンカのファイル名が入る
  • セクションとセグメントでは使われる時点が異なる
    • セクション はリンク時に ld が使う
    • セグメント は実行時に使われる
  • readelf --sections hello でセクションのメタデータをより詳しく見られる
  • 例のフラグを見ると各セクションの性質が分かる
    • .text: 実行可能で読み取り専用
    • .rodata: 読み取り専用
    • .data: 読み書き可能
    • .bss: 書き込み可能なデータ領域

逆アセンブルで機械語をアセンブリとして見る

  • .text セクションには、CPU がコードとして解釈して実行するバイト列が入っている
  • 例の .text 先頭バイト 31 ed は人間にはすぐ意味が分からないため、逆アセンブラ が必要になる
  • objdump -d ./hello.text セクションを逆アセンブルし、アセンブリ命令として表示する
  • 例の出力では 31 edxor %ebp,%ebp と表示される
  • この方法で、バイナリ内のコードバイトがどのアセンブリ命令に対応するか確認できる

セグメントは実行時のメモリ配置を決める

  • 実行ファイルは セグメント、またはプログラムヘッダ(program headers)でも構成される
  • readelf --segments hello はプログラムのセグメントと、セクション-セグメントの対応付けを表示する
  • セグメントはプログラム各部をメモリ上でどう分けて配置するかを決めるために使われる
  • 例には 2 つの主要な LOAD セグメントがある
    • 1 つ目の LOAD: R E と表示され、読み取りと実行が可能
    • 2 つ目の LOAD: RW と表示され、読み取りと書き込みが可能
  • .text は読み取りと実行が必要だが書き込みは不要なので、1 つ目のセグメントに入る
  • .data.bss は書き込み可能である必要があるが実行する必要はないため、2 つ目のセグメントに入る

さらに見るためのツールと資料

1件のコメント

 
GN⁺ 2024-02-03
Hacker News のコメント
  • 別スレッドでも述べたように https://news.ycombinator.com/item?id=38847750#38862450ELF を手で一度書いてみることを強くおすすめします
    実行ファイルの基本構成要素を理解するよい練習になりますし、この記事とは逆に上から下ではなく下から上へアプローチしたい場合にも役立ちます
    その別の HN 投稿の複数のスレッドにも、よい議論がたくさんあります

    • 最近、ELF ファイルを自分で書いてみました: https://github.com/avik-das/garlic/blob/master/recursive/elf...
      形式を自分や他の人に説明するために、ファイルのバイト列を見せるインタラクティブな可視化も作りました
      バイトをクリックすると説明が表示され、ファイル内の関連するバイトが強調表示されて理解の助けになります: https://scratchpad.avikdas.com/elf-explanation/elf-explanati...
    • 同様に、簡単な ELF ローダを書いてみるのもおすすめです
      動的リンクにはかなりの実装上の複雑さがありますが、静的 ELF だけをサポートするならかなり直感的です
    • 既存の ELF を修正するのも非常に勉強になり、楽しいです
      最初は動作しないときのデバッグが実質的に不可能でイライラしますが、ついに動き始めると本当に素晴らしいです
      ELF は興味深い方法でパッチできますし、補助ベクタを使えば実行中の自己観察も可能です
      Linux がプログラムヘッダテーブルのアドレスを渡してくれるので、そこからどこへでも到達できますし、LOAD セグメントをバイナリ全体を覆うように拡張すればよいのです
      例えば、私の Lisp インタプリタ実行ファイルの中に Lisp モジュールとコードを直接挿入するツールを作りました
      挿入されたセグメントは ELF で自動的にロードされ、インタプリタがそれを見つけて実行します
      この小さな機能がとても気に入ったので記事も書きました: https://www.matheusmoreira.com/articles/self-contained-lone-...
      主流の言語もこういう方式を採用してくれるとよいのですが
    • 自分でやってみると、実質的には手作業のアセンブリに近いです
      ドキュメントを読み、プロセッサのデータシートから必要なバイトを選び、それを複数のセクションに順番に配置し、ELF フィールドを埋めると、結局はすべて入力する作業に行き着きます
      ELF 以前の時代の 8 ビット Apple II のような環境では、機械語モニタがプログラムのバイト列を直接入力させてくれ、そのバイト列が実行されました
      ディスクに保存するのは少し複雑になるだけで、そこにもまた機会があります
      ディスクセクタエディタでファイルを作ることもでき、そうやって続いていきます
    • Chris Wellons の A Magnetized Needle and a Steady Hand は、ELF 実行ファイルをゼロから作る記事です: https://nullprogram.com/blog/2016/11/17/
  • 私の理解では、main シンボルはC に特有のものです
    _start シンボルは言語に依存しないバイナリのエントリポイントで、この場合は main を呼び出します
    エントリポイントを _start と呼び、そこに mainargc/argv を渡す、といった慣例があったなら、形式の柔軟性ははるかに低くなっていたでしょう

    • 厳密に言えば、_start という名前も特別ではありません
      バイナリはヘッダにエントリポイントのアドレスを書いておき、オペレーティングシステムはそのアドレスから実行を開始します
      そのシンボルを _start と呼ぶのは C や他の言語の慣例にすぎず、リンカが ELF ヘッダを書く際にエントリポイントを設定するために使います
      自分でリンカスクリプトを書くなら、エントリポイント名は好きなように付けられます
    • main シンボルは、指摘のとおり hosted C でのみ提供されます
      freestanding C では任意のエントリポイントを持てます
      _start も単にリンカのデフォルト値にすぎず、-Wl,--entry="${symbol}" でよりよいシンボルを指定できますし、GCC は見苦しい -Wl なしで直接設定することもサポートしています
      また、エントリポイントは実際にはシンボルではなくポインタです
      リンカが指定されたシンボルのアドレスを取得し、それを ELF のエントリポイントとして設定しているだけです
      引数の個数と引数ベクタだけでなく、スタックには環境ベクタと補助ベクタも入っています
      プロセス開始コードは、これらの値をスタックから取り出して適切なレジスタに入れ、任意の C 関数を呼び出す程度の単純なものにできます
      エントリポイント自体は関数ではないので、戻る先がありません
      エントリポイントのコードは、main がステータスコードを返したときにプロセスがきれいに終了するよう、exit システムコールで終わる必要があります
      少なくとも Linux ではこのように動作します
    • 言語ランタイムによりますが、よくある処理の一つは0 でないグローバル静的値を初期化することです
      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やクロスプラットフォームライブラリなど、フラクタルのようなあらゆる方法でクロスプラットフォーム問題を解こうとしてきたのに、解決策はずっと目の前にあったわけです

    • 個人的には、ポータブルなバイナリが差し引きで良いものなのか確信がありません
      高速なコンピューターの時代には、ソース配布とローカルコンパイルのほうがバイナリ配布より良いと思います
      残念ながら、私たちが依存しているソフトウェアの多くは大きすぎ、コンパイラは相対的に遅いため、バイナリ配布は一種の必要悪になっています
      ポータブルなバイナリよりも、自然に速くコンパイルできるより単純なソフトウェア構成要素と、より高速なコンパイラにもっと労力が注がれるとよいと思います
    • 私が誤解しているのかもしれませんが、APEはそれ自体がバイナリ形式というわけではないように思います
      どのシステムでも実行できるスクリプトで、そのスクリプトがバイナリをロードできるものです
      記憶が正しければ、元のバージョンはロード前に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で、肉眼で見るには同じくらい難解ではあります