eBPFのリバースエンジニアリング入門

目次

  • はじめに
  • eBPFとは?
  • eBPFのCTFチャレンジ
  • Flagの獲得
  • おわりに

はじめに

初めまして、Threat Detection and ResponseチームのChihiroです。昨年の7月に株式会社メルカリに入社して、主にクラウド向けのDetection Engineeringや、インシデントレスポンスを担当しています。また、メルカリで自社開発しているSOAR(Secuirty Orchestration Automation and Response)プラットフォームの開発や運用も担当しています。

メルカリには、部活を支援する社内制度が存在し、様々な部活があります。その部活の一環として、私は最近、CTF(Capture The Flag)と呼ばれるサイバーセキュリティの競技を楽しんでいます。そこで今回は、参加したCTFの中で面白かったeBPFに関するリバースエンジニアリングの問題を例にして、eBPFプログラムがどのように構成されており処理されていくのか解説します。

eBPFとは?

eBPFは、Linuxカーネル空間で動作し、パケットフィルタリングやパフォーマンス調査のためのトレーシングなどに活用されている技術です。また、近年はクラウドやセキュリティといった文脈でも活用されています。例えば、CNCFのプロジェクトとして有名なCNI(Container Network Interface)の1種であるCiliumや、コンテナのランタイムセキュリティのツールであるFalcoなどに利用されています。

eBPFのプログラムは、Linuxカーネル上にて、サンドボックスのような仮想マシン上で実行されるため、独自の命令仕様をもっています。そこで、簡単にeBPFのバイトコードを実行する仮想マシンの仕様についてご紹介します。詳しい仕様は、eBPF Instruction Setに記載されているので、合わせてご覧ください。

通常プログラム言語には、変数のような算出された数値を格納するための場所があります。今回の仮想マシンでは、それに相当するレジスタと呼ばれる小規模な記憶領域が利用されます。eBPFの命令セットでは10個の汎用レジスタが存在します。

  • R0: 関数からの戻り値や、eBPFプログラムが終了するときのステータスコードを格納
  • R1 – R5: 関数の引数が格納される
  • R6 – R9: 汎用的に用いることができる
  • R10: スタックフレームのアドレスを格納する

次に、命令について見ていきます。eBPFの命令はRISCアーキテクチャで使われる命令のように固定長です。1命令は、64bitになっています。具体的には、下記のように構成されています。

オペコードとは、命令の種類を表しており、数値を転送先レジスタ代入する命令や、加減算をする処理、条件分岐のための処理などが存在します。そして、このオペコードはさらに細かく構成されています。即値には、実際に代入する数値のデータが格納されることがあります。

1つ例を見てみましょう。下記のような64bitの数値の命令を明らかにしていきます。リトルエンディアンの表記になっているため一番左が下位byteな点に注意してください。

b7 01 00 00 44 04 05 1c

まず、b7の部分がオペコードの8 bitsになります。b7を2進数に直すと1011 0111となります。下位3bitの111、つまり7はBPF_ALU64という命令の種類を表します。

1011は、BPF_ALU64においては、BPF_MOVという命令として定義されており、転送元レジスタから転送先レジスタへ代入をする命令となります。残りの4bit目の0は転送元レジスタが、32bitの即値であるかレジスタであるかを決めるパラメータとなっています。この値が0の場合は、即値が利用されます。

次に2byte目の01です。これは、2進数として表すと0000 0001となります。図に示したように、それらは4bitずつ転送元と転送先レジスタに分割されます。つまり、転送先が1すなわちR1レジスタ、転送元がR0レジスタとなっています。

しかしながら、先ほど見たように転送元はレジスタではなく即値を使うため、0x1c050444という即値をR1レジスタに代入する命令だと解釈することができます。

eBPFのCTFチャレンジ

本問題は、Backdoor CTFというCTFの初心者向けのリバースエンジニアリング問題になります。CTFtime.orgによると、Backdoor CTFは2013年頃から開催されているようです。

CTFでは、様々なコンピュータサイエンスやサイバーセキュリティに関するクイズを解いて、フラグと呼ばれる特定のフォーマットの文字列、FLAG{COOL_FLAG_NAME}を見つけ出すことがゴールになります。例えば、リバースエンジニアリングの問題では、バイナリファイルを解析することで、隠されているフラグを得ることができる問題が一般的です。

リバースエンジニアリングの問題では、LinuxやWindowsのバイナリファイルを解析することが多いです。しかし、別のファイルフォーマットを解析することもあります。そのため、最初にfileコマンドを使って、ファイルタイプを特定することが有用です。下記の通り、このファイルは、eBPFのプログラムだと判明しました。

root@6d1def7da3d3:~# file babyebpf.o
babyebpf.o: ELF 64-bit LSB relocatable, eBPF, version 1 (SYSV), not stripped

この問題に対しては、2つのアプローチがあります。1つ目は実際にこのeBPFのコードを動かすことです。そしてもう1つは実際にどんな命令が記載されているのかを読んでいく手法です。今回は、興味のために、後者のアプローチでやっていこうと思います。

しかしながら、先ほど見たように、バイナリファイル内に含まれるすべての命令を手作業で解析していては大変です。そこで、これらの作業を自動化するための手法である逆アセンブルと呼ばれる変換作業をします。逆アセンブルでは、機械語を人間が読みやすいニーモニックと呼ばれる機械語に対応する文字列命令に変換します。

eBPFバイトコードの場合は、llvm-objdumpコマンドがおすすめです。-dフラグを使うことで対象ファイルの逆アセンブルをすることができます。通常、ニーモニックと同時に16進数も表示されるのですが、ここでは冗長なので--no-show-raw-insnフラグを使って非表示にしています。

root@6d1def7da3d3:~# llvm-objdump --no-show-raw-insn -d babyebpf.o

babyebpf.o: file format elf64-bpf

Disassembly of section tp/syscalls/sys_enter_execve:

0000000000000000 <detect_execve>:
       0:   r1 = 0x1c050444
       1:   *(u32 *)(r10 - 0x8) = r1
       2:   r1 = 0x954094701340819 ll
       4:   *(u64 *)(r10 - 0x10) = r1
       5:   r1 = 0x10523251403e5713 ll
       7:   *(u64 *)(r10 - 0x18) = r1
       8:   r1 = 0x43075a150e130d0b ll
      10:   *(u64 *)(r10 - 0x20) = r1
      11:   r1 = 0x0

0000000000000060 <LBB0_1>:
      12:   r2 = 0x0 ll
      14:   r2 += r1
      15:   r2 = *(u8 *)(r2 + 0x0)
      16:   r3 = r10
      17:   r3 += -0x20
      18:   r3 += r1
      19:   r4 = *(u8 *)(r3 + 0x0)
      20:   r2 ^= r4
      21:   *(u8 *)(r3 + 0x0) = r2
      22:   r1 += 0x1
      23:   if r1 == 0x1c goto +0x1 <LBB0_2>
      24:   goto -0xd <LBB0_1>

00000000000000c8 <LBB0_2>:
      25:   r3 = r10
      26:   r3 += -0x20
      27:   r1 = 0x1c ll
      29:   r2 = 0x4
      30:   call 0x6
      31:   r0 = 0x1
      32:   exit

簡単に逆アセンブル結果での命令の読み方を解説します。例えば、r1 = 10の場合は、r1レジスタに10を代入するという例です。他にメモリにデータを代入する際には*(u32*)(r10) = r1のような表記を用います。これは、r10レジスタの値をアドレスとして捉えて、そのアドレスが指すメモリにr1の値を代入するという意味になります。

では、実際にdetect_execve関数から処理を読んでいきます。

0000000000000000 <detect_execve>:
       0:   r1 = 0x1c050444
       1:   *(u32 *)(r10 - 0x8) = r1
       2:   r1 = 0x954094701340819 ll
       4:   *(u64 *)(r10 - 0x10) = r1
       5:   r1 = 0x10523251403e5713 ll
       7:   *(u64 *)(r10 - 0x18) = r1
       8:   r1 = 0x43075a150e130d0b ll
      10:   *(u64 *)(r10 - 0x20) = r1
      11:   r1 = 0x0

はじめに、r1レジスタに0x1c050444(10進数で470090820)を代入しています。次に、そのr1をr10-8が指すアドレスのメモリに格納しています。なお、r10レジスタはスタックフレームのアドレスを指すレジスタであることに注意してください。そのため、この処理は関数のローカル変数に値を代入しているコードだと読み解くことができます。そして似たような、データの代入をするコードがその後続いているのがわかります。また、最後にr1レジスタに0が格納されています。この処理が終わった後のスタックのイメージは下記の図の通りです。

さらに、逆アセンブル結果を読み進めていきます。ここでは先に関数の末尾の方を見てみましょう。

0000000000000060 <LBB0_1>:
      12:   r2 = 0x0 ll
      14:   r2 += r1
      15:   r2 = *(u8 *)(r2 + 0x0)
      16:   r3 = r10
      17:   r3 += -0x20
      18:   r3 += r1
      19:   r4 = *(u8 *)(r3 + 0x0)
      20:   r2 ^= r4
      21:   *(u8 *)(r3 + 0x0) = r2
      22:   r1 += 0x1
      23:   if r1 == 0x1c goto +0x1 <LBB0_2>
      24:   goto -0xd <LBB0_1>

そこには、if文があり、r1レジスタと0x1c(10進数で28)と比較しています。これらの値が等しかったら、LBB0_2ラベルにgotoします。そうでなければ、LBB0_1ラベルの先頭に戻ります。こうした処理は、高級言語におけるループ構文として認識することができます。事実、if文の前では、比較対象であるr1レジスタに1を加算する処理、つまりインクリメントが行われています。

では、このコードブロックにはループ文があるという前提で先頭から読んでいきます。まずr2レジスタに0を代入し、さらにr1レジスタの値を加算しています。初めはr1レジスタはdetect_execve関数で言及したように0が格納されているため、r2は加算されても0のままです。次にr2レジスタをアドレスとして使って、脱参照しr2レジスタに格納されているメモリ上の実際のデータを格納しています。

次に、命令の対象はr3レジスタへと変わります。r3レジスタにr10レジスタ、つまりスタックフレームのアドレスを格納します。その後、32を減算しています。この32は、ちょうどスタックフレームのアドレスから、先ほど代入したローカル変数のアドレスへのオフセットとなっています。さらに、そのアドレスに対してr1レジスタの値を足して、脱参照し、ローカル変数の値をr4レジスタに格納しています。そして、r2レジスタとr4レジスタの値をXORして、その結果をr2レジスタに格納し、最終的にr3レジスタが指す先、つまりローカル変数のアドレスが指すメモリ上のデータを、計算結果で書き換えています。

それ以降は、先ほど述べたように、r1レジスタを加算して、ループ処理のif文へと続きます。これにより、1byteずつずれながら、メモリ上の二つのデータへアクセスして、各1byteをXORして、ローカル変数の中身を上書きする処理が実行されていきます。つまり、何かしらのデータに対して、ローカル変数を使ってデータをデコードしている処理がこのeBPFプログラムの本質だとわかります。また、r1が28と比較していることから、両者のデータの想定されるデータ長は28byteだと推定することができます。

さて、少し話を戻します。r2レジスタにはどんなデータが入っているのでしょうか。逆アセンブル結果だけだと判断ができないため、少し視点を変えてバイナリを調査してみます。一般に、バイナリファイルには、特徴的な文字列などが含まれていることが多いです。そこで、GNU Binary Utilitiesのstringsコマンドを使って文字列を調査してみます。

root@6d1def7da3d3:~# strings -tx -a babyebpf.o
     5c G   T   {
    148 marinkitagawamarinkitagawama
    16e W>@Q2R
    179 G   T   D
    2a5 .text
    2ab detect_execve.____fmt
    2c1 _version
    2ca .llvm_addrsig
    2d8 detect_execve
    2e6 .reltp/syscalls/sys_enter_execve
    307 _license
    310 baby_ebpf.c
    31c .strtab
    324 .symtab
    32c .rodata
    334 LBB0_2
    33b LBB0_1
    342 .rodata.str1.1

いくつか特徴的な文字列はありますが、先ほど得た28byteというデータ長に着目して見ると、marinkitagawamarinkitagawamaという文字列は興味深いです。実際、byte数を確認してみると28byteでした。

root@6d1def7da3d3:~# echo -n marinkitagawamarinkitagawama | wc -c
28

では、最後にLBB0_2ラベルの処理を読んでいきます。

00000000000000c8 <LBB0_2>:
      25:   r3 = r10
      26:   r3 += -0x20
      27:   r1 = 0x1c ll
      29:   r2 = 0x4
      30:   call 0x6
      31:   r0 = 0x1
      32:   exit

このコードブロックで注目すべきは、call命令です。本命令の引数は6となっています。call命令は、eBPFプログラム内で定義したローカル関数とは別に、引数の整数値によって特定の関数を実行することができます。それらの関数と整数値のマッピングは、Linuxのソースコード上で定義されており、6はtrace_printk関数のようです。つまり、このコードは、何かしらデータを表示するコードだとわかります。また、r3レジスタに、ローカル変数のアドレスを格納しています。したがって、このプログラムは、XOR処理をしたデータを表示しようとするものだと推測することができます。

Flagの獲得

ここまでで、わかったことをスクリプトとして作成してみます。私は普段CTFで問題を解く際に、Rubyをよく使っているので、ここではRubyで書いたスクリプトを下記に示します。どんな言語でも問題ありません、ご自身の好きな言語で作成してみてください。

#!/usr/bin/env ruby
encoded = [
    0x43075a150e130d0b,
    0x10523251403e5713,
    0x954094701340819,
    0x1c050444
].pack('Q*').chars
key = "marinkitagawamarinkitagawama".chars

key.zip(encoded) do |k, e|
  print (k.ord ^ e.ord).chr
end

上記のRubyのスクリプトは、ローカル変数に代入されていた値と、バイナリファイル内に含まれていた文字列をバイト毎にXORした値を表示します。

これを実行すると、下記のように最終的にフラグを得ることができました。

root@6d1def7da3d3:~# ruby solve.rb
flag{1n7r0_70_3bpf_h3h3h3eh}

おわりに

この記事では、CTFの問題を題材に、eBPFプログラムの内部を解説しました。eBPFを間接的に使っている人は多いと思いますが、こうした裏側について知っている人は多くないと思います。本知識を直接的に、業務で使う機会は少ないかもしれませんが、デバッグやかなり細かい調査になってくると、もしかしたら役に立つ機会はあるかもしれません。

最後まで読んでくださってありがとうございました。本記事が何かの役に立てば幸いです。

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加