ELFバイナリの妥当な破壊

#until_lt0x05

lapla

https://slide.lapla.dev/zatsu/until0x05(.pdf)
アウトライン
  • whoami
  • TL;DR
  • ELF構造概観
  • プログラムの難読化
  • 各難読化手法の説明
whoami
  • Name:lapla
  • 学内所属
    • coins21
    • WORD編集部
    • システムソフトウェア研究室(OSSS)
    • etc...
  • 気持ち
    • システムプログラム
    • 分散システム
    • etc...
  • 他はlapla.dev/aboutを見てください
TL;DR
  • 既存のELFバイナリから,様々な手法で難読化(解析を阻む)されたものを生成できるCLIツールを作った
  • Rust製1^1lapla-cogito/cattleya
  • 基本は"ELFバイナリの実行には影響を与えない範囲で,バイナリの一部分を書き換える"行為で,すなわち妥当な破壊を行うといえる

1: 今回Rustの話はしません

Intro:ELF
  • Executable and Linkable Format
  • LinuxやBSD派生のOSなどで幅広く使用されている実行可能ファイルフォーマット1^1
    • 雑ですがWindowsだとPE(所謂exe),macOSだとMach-Oに相当する概念と思えば良いです
  • UNIX Systems Laboratoriesが開発して,Linux Foundationなど2^2で仕様が公開されている

1: 及びオブジェクトファイルフォーマット
2: 他にはSCOのWebサイトなど

Intro:プログラムの難読化
  • 攻撃目的,防御目的の両方で使われる
    • 攻撃側の例:マルウェアを難読化してアンチウイルスソフトに検知されにくくする
    • 防御側の例:ソースコードを難読化してリバースエンジニアリングに強くする1^1(ex: ゲーム)
  • 難読化自体は様々なレイヤーで行うことができるし,それぞれでできることが異なってくる
    • ソースコード
    • コンパイラ
    • バイナリ
単にバイナリをこねるだけでも様々な手法がある

1: 例えばAndroid Studioの中にはProGuardという難読化ツールがいて,apkの難読化(や最適化)をする

Intro:ELFバイナリの構造概観
  • 実際のところman elfを読むのが良い(めちゃくちゃちゃんと書いてある!!)が,この後の説明に必要な部分のみ簡単に触れる
  • ファイル先頭にはELFヘッダー(様々なメタデータが入っている)
  • いくつかのセクションがあり,それぞれ役割が違う
  • 各セクションのメタデータはセクションヘッダーテーブル(各セクションのメタデータ)に格納
  • ELFヘッダー → セクションヘッダーテーブル → セクション の順で辿っていける
左がリンク前,右がリンク後1^1

1: Tool Interface Standard (TIS) Executable and Linking Format (ELF) Specification Version 1.2

Intro:ELFヘッダー
$ readelf -h test_64bit
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:                              DYN (Position-Independent Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x1060
  Start of program headers:          64 (bytes into file)
  Start of section headers:          14032 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         13
  Size of section headers:           64 (bytes)
  Number of section headers:         31
  Section header string table index: 30
Intro:セクションヘッダーテーブル
$ readelf -S test_64bit
There are 31 section headers, starting at offset 0x36d0:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
(中略)
  [16] .text             PROGBITS         0000000000001060  00001060
       0000000000000202  0000000000000000  AX       0     0     16
  [17] .fini             PROGBITS         0000000000001264  00001264
       000000000000000d  0000000000000000  AX       0     0     4
  [18] .rodata           PROGBITS         0000000000002000  00002000
       000000000000001c  0000000000000000   A       0     0     4
  [19] .eh_frame_hdr     PROGBITS         000000000000201c  0000201c
       0000000000000044  0000000000000000   A       0     0     4
  [20] .eh_frame         PROGBITS         0000000000002060  00002060
       00000000000000ec  0000000000000000   A       0     0     8
  (中略)
  [28] .symtab           SYMTAB           0000000000000000  00003040
       0000000000000390  0000000000000018          29    18     8
  [29] .strtab           STRTAB           0000000000000000  000033d0
       00000000000001e4  0000000000000000           0     0     1
  [30] .shstrtab         STRTAB           0000000000000000  000035b4
       000000000000011a  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  D (mbind), l (large), p (processor specific)
Intro:シンボルテーブル
$ nm test_64bit
0000000000003dc8 d _DYNAMIC
0000000000003fb8 d _GLOBAL_OFFSET_TABLE_
0000000000002000 R _IO_stdin_used
                 w _ITM_deregisterTMCloneTable
                 w _ITM_registerTMCloneTable
(中略)
                 w __gmon_start__
                 U __libc_start_main@GLIBC_2.34
0000000000004010 D _edata
0000000000004018 B _end
0000000000001264 T _fini
0000000000001000 T _init
0000000000001060 T _start
0000000000004010 b completed.0
0000000000004000 W data_start
0000000000001090 t deregister_tm_clones
0000000000001149 T fac
000000000000119d T fib
0000000000001140 t frame_dummy
000000000000120c T main
                 U printf@GLIBC_2.2.5
00000000000010c0 t register_tm_clones
ELFバイナリの難読化手法

現在実装しているものだと以下がある(下に行くほど説明が難しめ):

  • エンディアン詐称
  • アーキテクチャ詐称
  • セクションヘッダーの情報消去
  • シンボル情報の消去
  • コメント情報の消去
  • 関数名暗号化
  • GOT overwriteによる難読化
エンディアン詐称
  • ELFヘッダーに存在する,エンディアンのメタデータを示す部分を書き換えて違うエンディアンと詐称する
    • little endianならbig endianと,big endianならlittle endianと詐称する
    • 仕様上ELFファイルの6バイト目が1ならlittle endian,2ならbig endian
  • 実行時にOSはまじめにここを見ない1^1が解析ツールはこの値をベースにデータを解釈する2^2ので,正しく実行はできるが解析に失敗するバイナリの完成
$ objdump -d res_endian
objdump: res_endian: file format not recognized

$ gdb res_endian
(中略)
pwndbg> b main
No symbol table is loaded.  Use the "file" command.

1: 見ても仕方がないから
2: 解析ツールはどのようなエンディアンのバイナリが来ても黙って解析する必要があるから

アーキテクチャ詐称
  • 前とほぼ同じ.ELFヘッダーに存在する,64bit向けか32bit向けかを示すバイトの値をもう片方のものに入れ替えて詐称する
    • 仕様上ELFファイルの5バイト目が1なら32bit,2なら64bit
  • この2つのように,対象を見定めればバイナリの1バイトを変えるだけでも"妥当な破壊"は可能
$ objdump -d res_class
objdump: res_class: file format not recognized

$ gdb res_class
(中略)
pwndbg> b main
No symbol table is loaded.  Use the "file" command.
セクションヘッダーの情報消去
  • セクションヘッダーテーブルをNULLで埋めることで解析ツールがセクションの情報を読めない
  • エントリーポイントはELFヘッダーにあるので,テーブルの情報を用いないプログラムは実行可能
$ readelf -S res_sechdr
There are 31 section headers, starting at offset 0x36d0:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0] <no-strings>      NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] <no-strings>      NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
(中略)
  [30] <no-strings>      NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
(中略)
readelf: Error: no .dynamic section in the dynamic segment
シンボル情報の消去
  • シンボルテーブルにあるシンボル名をNULLで埋める
  • 関数などへのアクセスはアドレスを使って行うので問題なく実行できる
$ nm res_symbol
000000000000038c r
0000000000001090 t
00000000000010c0 t
0000000000001100 t
0000000000004010 b
0000000000003dc0 d
0000000000001140 t
0000000000003db8 d
0000000000002148 r
(後略)
コメント情報の消去
  • セクションの1つとして存在する(場合が多い)コメントセクションをNULLで埋める
  • コメントセクションは通常どのコンパイラやOSの上でコンパイルされたなどの情報が入っている
$ readelf -x27 test_64bit

Hex dump of section '.comment':
  0x00000000 4743433a 20285562 756e7475 2031312e GCC: (Ubuntu 11.
  0x00000010 342e302d 31756275 6e747531 7e32322e 4.0-1ubuntu1~22.
  0x00000020 30342920 31312e34 2e3000            04) 11.4.0.
$ readelf -x27 res_comment

Hex dump of section '.comment':
  0x00000000 00000000 00000000 00000000 00000000 ................
  0x00000010 00000000 00000000 00000000 00000000 ................
  0x00000020 00000000 00000000 000000            ...........
関数名暗号化
  • コマンドラインオプションで指定された関数名とキーを用いて,関数名をAES 256bitで暗号化
  • 関数名が意味不明なものになると,解析者は逆アセンブルや逆コンパイル結果から関数の動きを推測しづらくなる
  • 当然実行時には,関数名が意味不明でも(ASCII外であっても)問題なく実行できる
$ objdump -d res_enc
(中略)
000000000000120c <main>:
    120c:       f3 0f 1e fa             endbr64
    1210:       55                      push   %rbp
    1211:       48 89 e5                mov    %rsp,%rbp
    1214:       48 83 ec 10             sub    $0x10,%rsp
    1218:       89 7d fc                mov    %edi,-0x4(%rbp)
    121b:       48 89 75 f0             mov    %rsi,-0x10(%rbp)
    121f:       bf 01 00 00 00          mov    $0x1,%edi
    1224:       e8 20 ff ff ff          call   1149 <�0,>
(後略)
GOT overwriteによる難読化
  • GOT overwriteという攻撃手法を難読化に転用
    • 以降GOT overwriteは攻撃の文脈と難読化の文脈で用いられるので,攻撃の文脈ではGOT overwrite attackと表記します
  • 詳しい人向け
    • ターゲットバイナリでPIEが無効であることとdynamic linkされることは仮定します
    • GOT overwrite attackは通常No RELROかPartial RELROが前提ですが,難読化の段ではこの仮定は不要です(Full RELROに対しても今回の難読化をすると勝手にPartial RELROになる)
GOT overwrite attack
  • CTFなどでよく見る攻撃手法
  • 共有ライブラリ関数の呼び出しを任意の関数(などの)呼び出しに変更する攻撃(これにより本来は呼び出されないsystem("/bin/sh")とかを呼び出してシェルを取ったりする)
  • この後少し攻撃原理の説明をやります
共有ライブラリの仕組み
  • 共有ライブラリは,物理メモリ上のあるアドレスに存在するが,各プロセスの仮想アドレスに対しては,異なるアドレスにマップされる
  • よって,オブジェクトロード時には,共有ライブラリのシンボル解決は行われていない
  • これを適当なタイミングでシンボル解決する仕組みがPLTとGOT
PLTとGOT
  • PLT(Procedure Linkage Table)は,GOTから対応する共有ライブラリ関数のアドレスを取得し,間接ジャンプする
  • GOT(Global Offsets Table)には各共有ライブラリ関数のアドレスが入る
    • 初期値はPLTにある,リロケーション処理を行う部分へのアドレス
    • 各共有ライブラリ関数の初回呼び出しでは,リロケーション部分に処理が移って,動的リンカがGOTにその関数の正しいアドレスを書き込む(正しいアドレスが遅延評価される)
    • 2回目以降の呼び出しではGOTに書かれた関数のアドレスに直接ジャンプする
  • ロード時に全てのシンボル解決を行うよりもオーバーヘッドを小さくしやすい
関数Aだけ初回呼び出しが終わった様子.他の関数BCのGOTエントリーはまだリロケーション処理に向いている
GOT overwrite attackの攻撃原理
  • GOTに記録されている,各共有ライブラリ関数のアドレスは遅延評価されるのだった
  • GOTのエントリーを上書きして,リロケーション処理ではなく,攻撃者が呼び出したい関数に向けてやれば,意図されていない挙動を引き起こすことができる → GOT overwrite attackの成立
関数BのGOTエントリーが攻撃により別の関数malに向いている.こうなると関数Bの呼び出しは全て関数malの呼び出しになる
GOT overwrite attackの難読化への転用
  • 元々ある関数fooを呼び出したいとする
  • ソースコード中ではこの部分を,ある共有ライブラリ関数Aを呼ぶものとしておく
  • GOT overwriteにより,GOTのAのエントリーにfooのアドレスを書き込む
  • これにより,ソースコード上ではAを呼び出している部分が,ELFにおいては実質的にfooの呼び出しになる
デモ用コード
  • main関数の中ではsystem関数しか呼ばれていない
    • のでこれを普通にコンパイルして実行するとsh: 1: secret?: not foundになる
  • secret関数は存在するが,どこからも呼ばれていない
// gcc got.c -no-pie -o res_got
#include <stdio.h>
#include <stdlib.h>

int secret(char* s) {
    if (s[0] == 's' && s[1] == 'e' && s[2] == 'c' && s[3] == 'r' && s[4] == 'e' && s[5] == 't' && s[6] == '?') {
        printf("secret function called\n");
    }

    return 0;
}

int main() {
    system("secret?");
}
GOT overwrite attackを難読化に転用した効果
  • ナイーブな静的解析では,main関数の中でsystem関数が呼ばれているようにしか見えない(が実際はsecret関数が呼び出されている)
$ ./res_got
secret function called

$ objdump -d res_got
(中略)
00000000004011e1 <main>:
  4011e1:       f3 0f 1e fa             endbr64
  4011e5:       55                      push   %rbp
  4011e6:       48 89 e5                mov    %rsp,%rbp
  4011e9:       48 8d 05 2b 0e 00 00    lea    0xe2b(%rip),%rax        # 40201b <_IO_stdin_used+0x1b>
  4011f0:       48 89 c7                mov    %rax,%rdi
  4011f3:       e8 68 fe ff ff          call   401060 <system@plt>
  4011f8:       b8 00 00 00 00          mov    $0x0,%eax
  4011fd:       5d                      pop    %rbp
  4011fe:       c3                      ret
(後略)
まとめ
  • ここまでやってきたことは,ELFバイナリの解析を阻むように妥当な破壊をするという行為
  • 今回紹介した難読化手法の重ね掛けをすれば,より強固な難読化が可能(実用性はさておき)
  • ここで述べた手法の実装は全てlapla-cogito/cattleyaにあります1^1
  • Future works
    • 自己解凍型パッカー(今書いている)
      • 圧縮されたプログラムを自身の中に持ち,実行時に解凍・実行を自身で行うバイナリを作る
    • x86向けのELFに対して命令間にjunk bytesを挟む
    • DWARF上のVM使って何か
      • DWARF(デバッグ情報のデータフォーマット)は独自のバイトコード(DWARF Expression)を謎のVMが解釈している.頑張ればここから標準出力とかもできる
    • Whitespace(esolang)のプログラムとインタプリタをバイナリに埋め込んで,実行時にinterpret
    • など他にも様々

1: お手元にRustの実行環境があるならcloneしてcargo testすると各手法で難読化したバイナリの例を作れます

役立つ資料たち