论文精读1
Behavior Nets: Context-Aware Behavior Modeling for Code Injection-Based Windows Malware
问题分析
摘要翻译
尽管人们在防御机制的研发上投入了大量精力,但新的恶意软件仍在不断快速发展, 使其成为互联网上的主要威胁之一。 恶意软件要想成功,开发者的最想要的就是尽可能长时间地逃避检测。 实现这一目标的方法之一是使用代码注入,即将恶意代码注入另一个良性进程,使其执行一些原本不该执行的操作。
自动检测和表征代码注入非常困难。 许多注入技术仅依赖于系统调用,而这些系统调用单独来看似乎是良性的,很容易与其他后台系统活动混淆。 因此,需要能够考虑单个系统事件所处上下文的模型,以便轻松区分相关活动。 在之前的工作中,我们首次对代码注入进行了系统性研究,以深入了解 Windows 平台上恶意软件开发人员可用的各种技术。 本文通过引入并形式化行为网络 (Behavior Nets) 扩展了这项工作:这是一种新颖的、可重用的、上下文感知的建模语言, 它通过可观察的事件及其一般的相互依赖关系来表达恶意软件行为。 这使得系统调用能够进行匹配,即使这些系统调用通常在良性环境中使用。 我们对行为网络进行了评估,并通过实验证实,将事件上下文引入行为特征在表征恶意行为方面比现有技术能取得更好的效果。 最后,我们针对未来如何基于动态分析开展恶意软件研究提出了宝贵的见解。
结果复现
作者Git仓库: https://github.com/utwente-scs/behavior-net
问题扩展 - Linux
在Linux平台上,文章提到的问题是否同样存在?
Step.1 文献搜索
Barabosch, T., Eschweiler, S., Gerhards-Padilla, E. (2014). Bee Master: Detecting Host-Based Code Injection Attacks. In: Dietrich, S. (eds) Detection of Intrusions and Malware, and Vulnerability Assessment. DIMVA 2014. Lecture Notes in Computer Science, vol 8550. Springer, Cham. https://doi.org/10.1007/978-3-319-08509-8_13
T. Barabosch and E. Gerhards-Padilla, "Host-based code injection attacks: A popular technique used by malware," 2014 9th International Conference on Malicious and Unwanted Software: The Americas (MALWARE), Fajardo, PR, USA, 2014, pp. 8-17, doi: 10.1109/MALWARE.2014.6999410. keywords: {Malware;Payloads;Complexity theory;Operating systems;Debugging}
从搜索结果可知,作者所提到的问题在Linux上同样存在,并且早已被广泛关注,但并不存在一个研究使用了作者的方法的“Linux版本”。
Step.2 扩展分析
一个很重要的问题是,自上面的两篇文章发布至今, Linux上的跨进程内存读写手段(Host-Based Code Injection行为的本质)是否有显著的变化?
答案是肯定的。
具体分析见第三节。
Step.3 扩展方向
- 基于最新版本的Linux生态,重新对现在Linux上真正广泛存在的“Host-Based Code Injection”问题进行建模。
- 尝试将本篇文献中提到的缓解方法迁移到Linux上,并对Linux平台的差异做适配。
- 研究行为图能否被结构化并使用深度学习技术进行分析建模。
今天Linux中的跨进程内存读写技术
近十年以来,Linux平台中的跨进程内存读写手段发生了较大的变化,这些变化主要并不集中于新增或删除,而是集中于收紧和沙箱化。 这主要是源于Linux社区的一个重要的开发、维护理念:“We do not break userspace”,这也是Linus的一句名言。
为了保持内核的向后兼容性,Linux不会轻易删除一个用户空间接口,除非他们有着一个明确的,非设计预期的“Bug”。 这意味着对于大多数的“符合设计预期的、合法的恶意利用”,Linux不得不保持接口原来的行为。
但这并不是说我们只能被动的接受,为了改善上述的问题,社区通常使用LSM(Linux Security Modules)。在我们的讨论主题中, 对Linux平台中的跨进程内存读写手段影响最大的LSM是Yama LSM。
什么是Yama LSM?
Yama 是一个轻量的 Linux Security Module (LSM),用于收紧 ptrace(和与之相关的 /proc/*/mem)权限, 避免任意同 UID 进程随意 attach/读写别人进程的内存,从而降低凭证窃取与进程注入风险。
历史上,任何同一 UID 的进程都能互相 ptrace,这给多用户主机、共享宿主机与容器环境带来了明显攻击面。 Yama 的目标是在不破坏现有调试工作流的前提下增加默认防护,防止横向滥用调试接口。
Yama会创建如上图所示的控制点:
/proc/sys/kernel/yama/ptrace_scope
默认情况下,这个控制点的值为“1”。其合法取值包括:
- 0:传统情况,同UID的进程可随意互相ptrace
- 1:限制模式,通常只允许父进程 ptrace 自己的子进程。
Yama的具体影响体现在ptrace的鉴权过程中,一个名为ptrace_may_access,位于linux/kernel/ptrace.c文件的函数中。
static int __ptrace_may_access(struct task_struct *task, unsigned int mode)
{
const struct cred *cred = current_cred(), *tcred;
struct mm_struct *mm;
kuid_t caller_uid;
kgid_t caller_gid;
if (!(mode & PTRACE_MODE_FSCREDS) == !(mode & PTRACE_MODE_REALCREDS)) {
WARN(1, "denying ptrace access check without PTRACE_MODE_*CREDS\n");
return -EPERM;
}
/* Don't let security modules deny introspection */
if (same_thread_group(task, current))
return 0;
rcu_read_lock();
if (mode & PTRACE_MODE_FSCREDS) {
caller_uid = cred->fsuid;
caller_gid = cred->fsgid;
} else {
caller_uid = cred->uid;
caller_gid = cred->gid;
}
tcred = __task_cred(task);
if (uid_eq(caller_uid, tcred->euid) &&
uid_eq(caller_uid, tcred->suid) &&
uid_eq(caller_uid, tcred->uid) &&
gid_eq(caller_gid, tcred->egid) &&
gid_eq(caller_gid, tcred->sgid) &&
gid_eq(caller_gid, tcred->gid))
goto ok;
if (ptrace_has_cap(tcred->user_ns, mode))
goto ok;
rcu_read_unlock();
return -EPERM;
ok:
rcu_read_unlock();
smp_rmb();
mm = task->mm;
if (mm &&
((get_dumpable(mm) != SUID_DUMP_USER) &&
!ptrace_has_cap(mm->user_ns, mode)))
return -EPERM;
return security_ptrace_access_check(task, mode);
}
bool ptrace_may_access(struct task_struct *task, unsigned int mode)
{
int err;
task_lock(task);
err = __ptrace_may_access(task, mode);
task_unlock(task);
return !err;
}
Yama会在security_ptrace_access_check这个层次介入,但具体的介入方式在这里没有必要深入讨论, 只需要知道Yama会影响ptrace_may_access的结果即可。
需要进一步指出的是,Yama这个LSM的影响范围绝不局限于ptrace调用,而是更加全面,下面将逐个说明。
/proc/<pid>/mem
/proc/<pid>/mem是Linux在Vfs中创建的一个重要的控制节点,读写该文件相当于读写进程的内存,其在Linux源码的linux/fs/proc/base.c文件中实现。
static const struct file_operations proc_mem_operations = {
.llseek = mem_lseek,
.read = mem_read,
.write = mem_write,
.open = mem_open,
.release = mem_release,
.fop_flags = FOP_UNSIGNED_OFFSET,
};
重点关注mem_open函数,file_operations中定义的open接口通常决定了访问是否被允许:
struct mm_struct *proc_mem_open(struct inode *inode, unsigned int mode)
{
struct task_struct *task = get_proc_task(inode);
struct mm_struct *mm;
if (!task)
return ERR_PTR(-ESRCH);
mm = mm_access(task, mode | PTRACE_MODE_FSCREDS);
put_task_struct(task);
if (IS_ERR(mm))
return mm == ERR_PTR(-ESRCH) ? NULL : mm;
/* ensure this mm_struct can't be freed */
mmgrab(mm);
/* but do not pin its memory */
mmput(mm);
return mm;
}
static int __mem_open(struct inode *inode, struct file *file, unsigned int mode)
{
struct mm_struct *mm = proc_mem_open(inode, mode);
if (IS_ERR_OR_NULL(mm))
return mm ? PTR_ERR(mm) : -ESRCH;
file->private_data = mm;
return 0;
}
static int mem_open(struct inode *inode, struct file *file)
{
if (WARN_ON_ONCE(!(file->f_op->fop_flags & FOP_UNSIGNED_OFFSET)))
return -EINVAL;
return __mem_open(inode, file, PTRACE_MODE_ATTACH);
}
通过追踪调用链, 不难注意到鉴权的核心逻辑位于函数 mm_access (mem_open -> __mem_open -> proc_mem_open -> mm_access) 中。
沿着 mm_access 继续追踪调用链:
struct mm_struct *mm_access(struct task_struct *task, unsigned int mode)
{
struct mm_struct *mm;
int err;
err = down_read_killable(&task->signal->exec_update_lock);
if (err)
return ERR_PTR(err);
mm = get_task_mm(task);
if (!mm) {
mm = ERR_PTR(-ESRCH);
} else if (!may_access_mm(mm, task, mode)) { // <- 从此处进一步深入
mmput(mm);
mm = ERR_PTR(-EACCES);
}
up_read(&task->signal->exec_update_lock);
return mm;
}
static bool may_access_mm(struct mm_struct *mm, struct task_struct *task, unsigned int mode)
{
if (mm == current->mm)
return true;
if (ptrace_may_access(task, mode)) // <- 兜兜转转,殊途同归
return true;
if ((mode & PTRACE_MODE_READ) && perfmon_capable())
return true;
return false;
}
最终我们会发现,经过调用链 mm_access -> may_access_mm -> ptrace_may_access, mem_open 接口最终通过 ptrace_may_access 检查了调用者是否有权访问 /proc/<pid>/mem文件
故而,毫无疑问地,Yama同样影响了 /proc/<pid>/mem 的访问逻辑,使其受到了:
只允许父进程 ptrace 自己的子进程
的限制。
process_vm_readv/writev
process_vm_readv/writev 是两个独立的系统调用,类似于Windows中的Read/WriteProcessMemory。 需要指出的是,Behavior Nets: Context-Aware Behavior Modeling for Code Injection-Based Windows Malware 的 作者在2.2节中所说的 NtReadVirtualMemory、NtWriteVirtualMemory实际上是Read/WriteProcessMemory在更底层的链接库中的形式。
在kernel32.dll中,Read/WriteProcessMemory API被定义,并且在内部实现中引用了 ntdll, NtReadVirtualMemory、NtWriteVirtualMemory 在 ntdll 中被实现,这里也是系统调用的实际上下文交换发生的地方。
那再进一步呢?内核中发生了什么?这就难说了,毕竟Windows是闭源的。尽管可以通过逆向工程的方式窥见一二,但这不是现在的主题。
与之不同的是,我们完全可以追踪到 process_vm_readv/writev 在内核层面做了哪些事,一个不错的切入点是系统调用表。
/arch/x86/entry/syscalls/syscall_64.tbl:
310 64 process_vm_readv sys_process_vm_readv
311 64 process_vm_writev sys_process_vm_writev
可以明确的看到,310和311号系统调用对应 process_vm_readv/writev, 而在 linux/include/linux/syscalls.h 中,可以找到这两个系统调用的原型:
asmlinkage long sys_process_vm_readv(pid_t pid,
const struct iovec __user *lvec,
unsigned long liovcnt,
const struct iovec __user *rvec,
unsigned long riovcnt,
unsigned long flags);
asmlinkage long sys_process_vm_writev(pid_t pid,
const struct iovec __user *lvec,
unsigned long liovcnt,
const struct iovec __user *rvec,
unsigned long riovcnt,
unsigned long flags);
进一步地,这两个系统调用的实现可以在 linux/mm/process_vm_access.c 中被找到:
SYSCALL_DEFINE6(process_vm_readv, pid_t, pid, const struct iovec __user *, lvec,
unsigned long, liovcnt, const struct iovec __user *, rvec,
unsigned long, riovcnt, unsigned long, flags)
{
return process_vm_rw(pid, lvec, liovcnt, rvec, riovcnt, flags, 0);
}
SYSCALL_DEFINE6(process_vm_writev, pid_t, pid,
const struct iovec __user *, lvec,
unsigned long, liovcnt, const struct iovec __user *, rvec,
unsigned long, riovcnt, unsigned long, flags)
{
return process_vm_rw(pid, lvec, liovcnt, rvec, riovcnt, flags, 1);
}
可以很明确的看到,实际上 process_vm_readv 和 process_vm_writev 的实现指向了一个相同的内核函数 process_vm_rw, 只不过 process_vm_writev 传入了一个为1的写标志。
process_vm_rw 的定义如下:
static ssize_t process_vm_rw(pid_t pid,
const struct iovec __user *lvec,
unsigned long liovcnt,
const struct iovec __user *rvec,
unsigned long riovcnt,
unsigned long flags, int vm_write)
{
struct iovec iovstack_l[UIO_FASTIOV];
struct iovec iovstack_r[UIO_FASTIOV];
struct iovec *iov_l = iovstack_l;
struct iovec *iov_r;
struct iov_iter iter;
ssize_t rc;
int dir = vm_write ? ITER_SOURCE : ITER_DEST;
if (flags != 0)
return -EINVAL;
/* Check iovecs */
rc = import_iovec(dir, lvec, liovcnt, UIO_FASTIOV, &iov_l, &iter);
if (rc < 0)
return rc;
if (!iov_iter_count(&iter))
goto free_iov_l;
iov_r = iovec_from_user(rvec, riovcnt, UIO_FASTIOV, iovstack_r,
in_compat_syscall());
if (IS_ERR(iov_r)) {
rc = PTR_ERR(iov_r);
goto free_iov_l;
}
rc = process_vm_rw_core(pid, &iter, iov_r, riovcnt, flags, vm_write); // <- 看这里看这里
if (iov_r != iovstack_r)
kfree(iov_r);
free_iov_l:
kfree(iov_l);
return rc;
}
从定义中可以看出,process_vm_rw 将处理逻辑推后到了 process_vm_rw_core 函数。 而对于这个函数,我们的分析可以掐头去尾:
static ssize_t process_vm_rw_core(pid_t pid, struct iov_iter *iter,
const struct iovec *rvec,
unsigned long riovcnt,
unsigned long flags, int vm_write)
{
// ... 前省略
/* Get process information */
task = find_get_task_by_vpid(pid);
if (!task) {
rc = -ESRCH;
goto free_proc_pages;
}
mm = mm_access(task, PTRACE_MODE_ATTACH_REALCREDS); // <- 这里是关键
if (IS_ERR(mm)) {
rc = PTR_ERR(mm);
/*
* Explicitly map EACCES to EPERM as EPERM is a more
* appropriate error code for process_vw_readv/writev
*/
if (rc == -EACCES)
rc = -EPERM;
goto put_task_struct;
}
// ... 后省略
}
源代码中最关键的一点是,mm_access最终被用来做鉴权,而我们已经证明过, mm_access 最终所依赖的是 ptrace_may_access 调用链为 mm_access -> may_access_mm -> ptrace_may_access。
故而,我们已经可以得出 process_vm_readv/writev 同样受Yama的影响的结论了。
例外的情况
需要额外指出的是,Yama并不会阻止root进程对非root进程进行ptrace,因此下面的实验将以攻击者已经root机器为前提,他们下一步的目的是把自己的某个任务(可能是后门、挖矿程序)隐藏在某个正常的程序之中。
实验验证
一个靶程序
为了后续的实验方便,有必要编写一个有基本IO功能,但没有复杂行为的“靶程序”。
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
int main()
{
pid_t pid = getpid();
std::cout << "Pid: " << pid << std::endl;
while(true)
{
sleep(0);
}
}
如代码所示,该程序运行后只是简单的展示自己的pid,然后不断地让出CPU控制权。 这里没有用IO操作(如getline)去阻塞的原因是那会让OS不调度这个进程,进程会持续在后台休眠,而现实中的进程通常总是在做一些事。 如果用getline去阻塞进程,那么这个靶程序可能会一直阻塞在syscall里面——而不去执行我们主动注入的代码。
