学科の基礎実験でAssemblyでbrainfuckのコンパイラをかけという選択課題があります。この課題をやろうと思うと、ELFをある程度理解し、自分で生成できる必要があります。
今回、最低限の機械語を実行可能なELFフォーマットで出力できるようになるために手でELFを組み上げていき理解を深めてみようと思いました。
もちろんELFと言っても用途が様々ですが、ここでの目標は次の機械語の列を実行して、シェルが起動するということとします。
出展:http://shell-storm.org/shellcode/files/shellcode-806.php
お気持ちとしては、「たまにはシェルコードもエクスプロイトされずに実行されたいよね」です。
実際、Brainfuck -> 機械語のコンパイル結果はそれ自体に関数とか複数のオブジェクトファイルがリンクされているだとかそういう"構造"はなく、ただ単に機械語の列になると思うので、課題を達成するための理解として第一段階はそれでいいだろうという話でもあります。
方針
道具
ELFを手で書くと言っても、さすがに鉛筆で書くことはできません(メモリにマップすることができないため)。ここではpythonのアシストを借りつつ最終的にELFをダンプするようなスクリプトを書いていこうと思います。
その上で、ELFを書く上で、8~64bitのリトルエンディアンのpackが必要になることが多々あるため、次のようなショートカット関数を定義しておきます
import struct def p8(x): return struct.pack("<B", x) def p16(x): return struct.pack("<H", x) def p32(x): return struct.pack("<L", x) def p64(x): return struct.pack("<Q", x)
また、entrypointと、実際に実行する機械語として、
code = '\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05' entry_point = 0x400078
を定義しておきます。
全体像
ELFのヘッダについての詳細な情報は日本語や英語版Wikipediaなどに詳しく書いてあるので一つ一つ書くことはしてもしょうがないのですが、重要となる点についていくつか書きます。
まず、ELFは、ELF Header、Program Headerそして、Section Headerを持ちます。ELF Headerはただ一つ存在しELFファイル全体を管轄するメタデータです。 また、Program Headerはメモリにマップする、"され方"を定義するものです。つまり、メモリ上のどのアドレスにどのような領域(Read/Write? Executable?)をマップするかをとりしきります。 そして、Section Headerはオブジェクトファイルをリンクしたりする際のメタデータを持ちます。今回は外部のライブラリをロードしたりすることはないので、このHeaderは登場しません。
出展:Wikipedia
今回書くELFの構造的なイメージは次のような感じです。
このような形でマップされるようなELFを書いていきます。
環境
今回書くのは、64bit ELFバイナリ(x86-64)です。環境は
$ uname -a Linux ubuntu-xenial 4.4.0-97-generic #120-Ubuntu SMP Tue Sep 19 17:28:18 UTC 2017 x86_64 x86_64 x86_64 GNU/Linux $ uname -a Linux ubuntu-xenial 4.4.0-97-generic #120-Ubuntu SMP Tue Sep 19 17:28:18 UTC 2017 x86_64 x86_64 x86_64 GNU/Linux $ lsb_release -a No LSB modules are available. Distributor ID: Ubuntu Description: Ubuntu 16.04.3 LTS Release: 16.04 Codename: xenial
です。
ELFヘッダを書く
まず必要となるのがELFヘッダです。これは書かないことには始まりません。次がELFヘッダです
# ELF Header elfh = '' elfh += '\x7fELF' # magic elfh += p8(2) # Class: 2 is 64bit elfh += p8(1) # Endinan: little endian elfh += p8(1) # ELF Version: EV Current elfh += p8(0) # EI_OSABI: It is often set to 0 regardless of the target platform.(Wikipedia) elfh += p8(0) # ABI version: (not used) elfh += p8(0) * 7 # Padding: currently unused elfh += p16(2) # e_type: 2 is exectable file elfh += p16(0x3e) # e_machine: 0x3e is x86-64 elfh += p32(1) # e_version: original version(maybe whatever) elfh += p64(entry_point) # e_entry: entry point elfh += p64(0x40) # e_phoff: program header table offset -> 64bit is 0x40 elfh += p64(0) # e_shoff: section header table offset -> notable elfh += p32(0) # e_flags: maybe whatever elfh += p16(0x40) # e_ehsize: ELF Header Size elfh += p16(0x38) # e_phentsize elfh += p16(1) # e_phnum: the number of program header entries elfh += p16(0) # e_shentsize: no section headers elfh += p16(0) # e_shnum: no section headers elfh += p16(0) # e_shstrndx: no section headers
一つ一つの内容については、Wikipediaを参照して欲しいですが、いくつか取り上げると、
elfh += p64(0x40) # e_phoff: program header table offset -> 64bit is 0x40
これは、上の図でいうと、Program Headerがすぐ下に続くことを意味しています。ELFで64bitのとき、ELF Headerの大きさは0x40なので、その大きさ分だけオフセットを書きます。
Program Header
今回必要になるのはただ一つのProgram Headerです。というのもマップする必要のある領域がただ一つであるためです。
prmh = '' prmh += p32(1) # p_type: type of segment prmh += p32(5) # p_flags: memory segment permission. 0b101 -> Exec + Read prmh += p64(0x40 + 0x38) # p_offset: offset of the segment prmh += p64(entry_point) # p_vaddr: virtual address to be mapped in memory prmh += p64(entry_point) # p_paddr: physical address to be mapped in memory (maybe not used in my machine) prmh += p64(len(code)) # p_filesz: file size of this segment prmh += p64(len(code)) # p_memsz: file size of this segment prmh += p64(0x1000) # p_align: alignment
これについては一行一行説明しようと思います
prmh += p32(1) # p_type: type of segment
これはセグメントのタイプを定義します。1がPT_LOADであり、ロード可能なデータに関するセグメントであることを意味しています。ロード可能であるとは、p_fileszやp_memszを持つということを意味しています。
prmh += p32(5) # p_flags: memory segment permission. 0b101 -> Exec + Read
これはマップする領域のpermissionを表します。1bit目がExec, 2bit目がWrite, 3bit目がReadに関するpermissionを意味しています。すなわち、5(0b101)はExecとReadができる領域であることを意味します
prmh += p64(0x40 + 0x38) # p_offset: offset of the segment
実際にこの領域が表すデータがファイルの先頭からのオフセットでどこにあるかを表します。最初に示した図を見てもられるとわかるとおり、Code自体はELF HeaderとProgram Header一つのすぐしたにあるので0x40 + 0x38であることがわかります。
prmh += p64(entry_point) # p_vaddr: virtual address to be mapped in memory prmh += p64(entry_point) # p_paddr: physical address to be mapped in memory (maybe not used in my machine)
これは、実行時にマップされるアドレスを表します。p_paddrは物理メモリを使う場合に使われる情報のようですが、僕の環境では関係ないと思われますし、基本的に普通のパソコンを使う限りは関係ない情報だと思われます。
prmh += p64(len(code)) # p_filesz: file size of this segment prmh += p64(len(code)) # p_memsz: file size of this segment
これは、それぞれfile上でのマップするデータの大きさと、メモリ上での大きさを表しています。今回は一致していますが、しばしばmemszはfileszより大きくてもよく、その場合0埋めがされます( Tips ELFフォーマットその1 ELFフォーマットについて)
prmh += p64(0x1000) # p_align: alignment
これはアラインメントを表します。
実際にダンプしてみる
この二つのHeaderに実行するコードをくっつけたファイルを吐き出すと実際に実行できます(完成したスクリプトは、ここにあります)。
ubuntu@ubuntu-xenial:~/ctf/experiment$ python snipet.py (64, 56, 27) 147 ubuntu@ubuntu-xenial:~/ctf/experiment$ ./dump $ echo hello hello $ readelf -e dump ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: EXEC (Executable file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x400078 Start of program headers: 64 (bytes into file) Start of section headers: 0 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 1 Size of section headers: 0 (bytes) Number of section headers: 0 Section header string table index: 0 There are no sections in this file. Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align LOAD 0x0000000000000078 0x0000000000400078 0x0000000000400078 0x000000000000001b 0x000000000000001b R E 1000
良さそうですね
終わりに
ELFファイルフォーマットについての知見が全くなかったので良い勉強になりました。brainfuck compiler on assembly書くぞの強い気持ち