GCCが出力した小さなバイナリからsection headerを除去したりする話

Binary Hacks に、「インラインアセンブラをちょっとだけ使って、gccに小さなhello worldバイナリを出力させる」というネタを書きました(Hack #25: glibcを使わないでHello Worldを書く)。その補足という訳でもないのですが、先日shinh先生と焼肉など食べておりましたところ、セクションヘッダを削ってサイズ縮小する話は紹介してもよかったんじゃまいかとツッコミをいただきましたので、(あまり親切な記述ではないですが)書いてみます。「gccにコードを吐かせる」「アセンブリ言語部分にあまり凝らない」という条件で、132バイトまで削ります(書籍だと488バイト)。


えー、hello worldソースコード(末尾に添付)を次のようにコンパイルして*1

% gcc -m32 -Os -fno-builtin -fomit-frame-pointer -fno-ident -c hello_world.c
% ld -m elf_i386 --entry=hello -o hello hello_world.o

strip -s hello して、次のような状態のhelloバイナリが得られたとします。手元のFC6環境だと396バイトです。

% readelf -S hello
There are 4 section headers, starting at offset 0xec:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] .text             PROGBITS        08048074 000074 000051 00  AX  0   0  4
  [ 2] .rodata           PROGBITS        080480c5 0000c5 00000e 01 AMS  0   0  1
  [ 3] .shstrtab         STRTAB          00000000 0000d3 000019 00      0   0  1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings)
  I (info), L (link order), G (group), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)

% wc -c hello
396 hello

このとき、このhelloの中身は次の4つがこの順に並んでます。

  1. ELFヘッダ
  2. プログラムヘッダ
  3. ほんたい (システムコールを呼ぶコードとか、出力する文字列とか)
  4. セクションヘッダ

ELF的には、ELFヘッダ以外はどこに置いても良いのですが、普通、というか私のldはこの順序で吐きます。で、セクションヘッダはプログラムを実行するだけなら削ってしまっても差し支えないので、削ってさらに小さなバイナリをゲットという作戦です。セクションヘッダの開始位置はELFヘッダに書いてあります。readelfの出力によると、236バイト目からだそうです。

% readelf -h hello | grep -i section
  Start of section headers:          236 (bytes into file)
  Size of section headers:           40 (bytes)
  Number of section headers:         4
  Section header string table index: 3

ddで236バイト目以降を捨てたバイナリを得ます*2

% dd if=hello of=hello_nosectionhdr count=235 bs=1
% chmod +x ./hello_nosectionhdr 
% ./hello_nosectionhdr 
Hello World!

% wc -c hello_nosectionhdr 
235 hello_nosectionhdr

これで、235バイトになりました。ELFヘッダに書いてあるセクションヘッダの位置/サイズと、実際のバイナリのそれが異なる状態になりますが、少なくともkernel 2.6.18現在ではちゃんと実行できます。readelfはエラーが出るようになり、objdumpでの逆アセンブル等は簡単にはできなくなり*3、elfutilsでの解析はほぼできなくなりますが、実行はできるという状態です。めでたし。helloをhexdumpしてみると、

% objdump -s -b binary hello_nosectionhdr 

hello_nosectionhdr:     file format binary

Contents of section .data:
 0000 7f454c46 01010100 00000000 00000000  .ELF............
 0010 02000300 01000000 9e800408 34000000  ............4...
 0020 ec000000 00000000 34002000 02002800  ........4. ...(.
 0030 04000300 01000000 00000000 00800408  ................
 0040 00800408 d3000000 d3000000 05000000  ................
 0050 00100000 51e57464 00000000 00000000  ....Q.td........
 0060 00000000 00000000 00000000 06000000  ................
 0070 04000000 8b542404 b8010000 005389d3  .....T$......S..
 0080 cd805bc3 53b80400 00008b5c 24088b4c  ..[.S......\$..L
 0090 240c8b54 24105389 dbcd805b 5bc3b9c5  $..T$.S....[[...
 00a0 800408b8 04000000 ba0d0000 0053bb01  .............S..
 00b0 000000cd 805bb801 00000053 bb000000  .....[.....S....
 00c0 00cd805b c348656c 6c6f2057 6f726c64  ...[.Hello World
 00d0 210a0000 2e736873 74727461 62002e74  !....shstrtab..t
 00e0 65787400 2e726f64 617461             ext..rodata     

こんな感じです。なおこのセクションヘッダをdelする作業、sstripというコマンドだと自動でやってくれたような気もします(未確認)。


以下余談(GOLF)です。ここで、「0xd2バイト目以降もいらないんじゃないか」と思ったあなたは正しくて、ここは最初のreadelf -Sでいうところの.shstrtabセクションで、いまは不要です。手元の strip -R だとshstrtabは消せないっぽいんですよね。ここもddで落としてしまいましょう。これで210バイトになります。

% dd if=hello of=hello_nosectionhdr count=$((0xd2)) bs=1

あと今回だと、

  • インラインアセンブラの %%ebxのpush/pop は消しちゃってもいい
  • write関数とexit関数をstatic inlineにして、ld に -x (delete all local symbols) を渡してwrite/exit関数の実体を捨てる
  • strip -gR .note.GNU-stack hello_world.o しておいて、ld がPT_GNU_STACKなプログラムヘッダを出力しないようにする (プログラムヘッダがPT_LOADひとつだけになる)。
    • PT_GNU_STACKは、スタックに置かれたコードの実行OK/NGを表現するためのprogram header*4。それだけで32バイト!ありえない!
    • PT_GNU_STACKがない場合のスタック上コードの実行可否は「可」のようですが、(少なくともこのネタでは)どっちだろうが関係ないことこの上ありません

とか色々hackできないこともないです。手元では上の3つと、shstrtab以降を捨てる技の併用で132バイトです。一応、書籍に載せた方法のバイナリの半分以下のサイズです。ヘッダが52+32=84バイトで、文字列が13バイト、コードが35バイトですね。コードが冗長な事を除いてはELF的に特に無駄な部分がないので、「アセンブリ言語部分に凝らない」「gccにELFバイナリを出力させる」という条件下では、このあたりが限界ですかねー?


これ以上こだわるなら、gccに頼らずにELFを直接出力するCのプログラムを書き、shinhさんらが遊んでいる(一部で話題の) "ELF Golf" をやることになります。アセンブリ言語部分を頑張って短くしたり、ヘッダの中にコードを埋めたり、2種類のヘッダをオーバーラップさせたりするアレです。まぁELF Golf自体は難解なのですが、ELFを吐くCのコードを書くだけならとっても簡単で楽しかったりしますので、その話を次に書きます予定書いた)。


一応、今回使用したソースコードも掲載しておきます。カーネルからコードを借りています。x86用です。

#include <asm/unistd.h>
#define __syscall_return(type, res) (type)(res)

#define _syscall1(type,name,type1,arg1) \
type name(type1 arg1) \
{ \
long __res; \
__asm__ volatile ("push %%ebx ; movl %2,%%ebx ; int $0x80 ; pop %%ebx" \
        : "=a" (__res) \
        : "0" (__NR_##name),"ri" ((long)(arg1)) : "memory"); \
__syscall_return(type,__res); \
}

#define _syscall3(type,name,type1,arg1,type2,arg2,type3,arg3) \
type name(type1 arg1,type2 arg2,type3 arg3) \
{ \
long __res; \
__asm__ volatile ("push %%ebx ; movl %2,%%ebx ; int $0x80 ; pop %%ebx" \
        : "=a" (__res) \
        : "0" (__NR_##name),"ri" ((long)(arg1)),"c" ((long)(arg2)), \
                  "d" ((long)(arg3)) : "memory"); \
__syscall_return(type,__res); \
}

inline _syscall1(int, exit, int, status);
inline _syscall3(int, write, int, fd,  const void*, buf, unsigned long, count);

void hello() {
  write(1, "Hello World!\n", 13);
  exit(0);
}

132バイトの奴。

% wc -c hello_nosectionhdr 
132 hello_nosectionhdr
% ./hello_nosectionhdr 
hello world!

% objdump -s -b binary hello_nosectionhdr 

hello_nosectionhdr:     file format binary

Contents of section .data:
 0000 7f454c46 01010100 00000000 00000000  .ELF............
 0010 02000300 01000000 54800408 34000000  ........T...4...
 0020 a0000000 00000000 34002000 01002800  ........4. ...(.
 0030 04000300 01000000 00000000 00800408  ................
 0040 00800408 85000000 85000000 05000000  ................
 0050 00100000 b9778004 08b80400 0000ba0d  .....w..........
 0060 000000bb 01000000 cd80b801 000000bb  ................
 0070 00000000 cd80c368 656c6c6f 20776f72  .......hello wor
 0080 6c64210a                             ld!.  

% readelf -hl hello_nosectionhdr 
readelf: Error: Unable to read in 0x28 bytes of section headers
ELF Header:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF32
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Intel 80386
  Version:                           0x1
  Entry point address:               0x8048054
  Start of program headers:          52 (bytes into file)
  Start of section headers:          160 (bytes into file)
  Flags:                             0x0
  Size of this header:               52 (bytes)
  Size of program headers:           32 (bytes)
  Number of program headers:         1
  Size of section headers:           40 (bytes)
  Number of section headers:         4
  Section header string table index: 3
readelf: Error: Unable to read in 0xa0 bytes of section headers

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x000000 0x08048000 0x08048000 0x00085 0x00085 R E 0x1000

(追記) Plan9での例がこちらに

*1:x86_64向けです。x86な人は-m32と-m elf_i386は省略してください

*2:コメント欄も参照のこと

*3:% objdump -b binary -m i386 --start-address=<ファイルオフセット> hello とかすれば一応可能

*4:余談ですがこれも日本語の情報がないなぁ...