0x00 碎碎念
都是一些linux内核的知识,很零碎,就是四处收集,然后整合起来的。
0x01 进程、线程相关(调度子系统)
参考资料:https://www.cnblogs.com/yanghaizhou/p/7705520.html
进程描述符(task_struct)
在linux内核中进程以及线程(多线程也是通过一组轻量级进程实现的)都是通过task_struct结构体来描述的,我们称它为进程描述符。每个任务都有一个,多线程的应用程序则每个线程都有一个。
部分结构体:
// [include/linux/sched.h]
struct task_struct {
volatile long state; // 当前进程的状态 (运行态, 阻塞态等等)
void *stack; // 任务的栈指针
int prio; // 进程的优先级
struct mm_struct *mm; // 内存地址空间
struct files_struct *files; // 指向文件描述符
const struct cred *cred; // 当前进程的
// ...
};
thread_info
thread_info则是一个与进程描述符相关的小数据结构,它同进程的内核态栈stack存放在一个单独为进程分配的内存区域,这么做的好处是得到stack,thread_info或task_struct任意一个数据结构的地址,就可以很快得到另外两个数据的地址。由于这个内存区域同时保存了thread_info和stack,采用联合体定义:
union thread_union {
struct thread_info thread_info;
unsigned long stack[THREAD_SIZE/sizeof(long)];
};
thread_info:
// [arch/x86/include/asm/thread_info.h]
struct thread_info {
struct task_struct *task; //指向task_struct
struct exec_domain *exec_domain;
__u32 flags; //flags:持有像_TIF_NEED_RESCHED 或者 _TIF_SECCOMP的标志
__u32 status;
__u32 cpu;
int preempt_count;
mm_segment_t addr_limit; //内核角度下最高的用户空间虚拟地址。使用于“软件保护机制”(参见获取任意读/写)
struct restart_block restart_block;
void __user *sysenter_return;
#ifdef CONFIG_X86_32
unsigned long previous_esp;
__u8 supervisor_stack[0];
#endif
int uaccess_err;
};
访问current宏获得,task_struct指针,用stack段,可以找到内核栈首地址,内核栈首地址就是thread_info地址。thread_info地址的task字段指向task_struct。
三个要点:
- 我们可以检索一个指向当前task_struct 的指针来泄露内核线程栈指针(因此有很多内核数据结构)。
- 通过重写flag段,我们可以禁用seccomp 保护甚至沙箱逃逸。
- 我们可以取得一个人任意读/写通过改变addr_limit节的值。
具体见https://blog.csdn.net/weixin_42177005/article/details/104234951(核心内容部分)
current宏
current宏,是一个全局指针,指向当前进程的struct task_struct结构体,即表示当前进程。例如current->pid就能得到当前进程的pid,current-comm就能得到当前进程的名称。
任务状态
struct task_struct中的state字段:
Running:正在运行或者准备就绪只等在cpu上运行了。
Waiting:在等待某个事件或者资源。
任务常见状态(不止这两种,只是比较常见):
// [include/linux/sched.h]
#define TASK_RUNNING 0
#define TASK_INTERRUPTIBLE 1
// ... cut (other states) ..
TASK_RUNNING:运行队列的任务。它可以现在正在cpu上运行,也可以在不久的将来运行(由调度器选择)。
TASK_INTERRUPTIBLE:等待任务的最常见状态,“等待”任务未在任何CPU上运行。它可以由等待队列或信号唤醒。
可以直接修改state字段,也可以通过__set_current_state()来设置state字段。
// [include/linux/sched.h]
#define __set_current_state(state_value) \
do { current->state = (state_value); } while (0)
运行队列
struct rq(run queue)是调度器最重要的数据结构之一。运行队列中的每个任务都将由CPU执行。每个CPU都有自己的运行队列(允许真正的多任务处理)。运行队列(run queue)具有一个任务(由调度器选择在指定的CPU上运行)列表。还具有统计信息,使调度器做出“公平”选择并最终重新平衡每个cpu之间的负载(即cpu迁移)。
结构体:
// [kernel/sched.c]
struct rq {
unsigned long nr_running; // <----- statistics
u64 nr_switches; // <----- statistics
struct task_struct *curr; // cpu上正跑着的任务
// ...
};
ps:“完全公平调度器(CFS)”的任务列表的存储方式更加复杂,但在这里并没有太大影响。
deactivate_task()函数将任务从运行队列中移出。
activate_task()将任务加入到运行队列中。
等待队列
任务等待资源或特殊事件非常普遍。例如,如果运行服务器(客户端-服务器(Client/Server)架构里的Server),主线程可能正在等待即将到来的连接。除非它被标记为“非阻塞”,否则accept()系统调用将阻塞主线程。也就是说,主线程将阻塞在内核中,直到其他东西唤醒它。
等待队列基本上是由当前阻塞(等待)的任务组成的双链表。与之相对的是运行队列。队列本身用wait_queue_head_t表示:
// [include/linux/wait.h]
typedef struct __wait_queue_head wait_queue_head_t;
struct __wait_queue_head {
spinlock_t lock;
struct list_head task_list;
};
ps:struct list_head是是Linux实现双链表的方式。
队列种的每个元素都具有wait_queue_t,其结构体为:
// [include/linux.wait.h]
typedef struct __wait_queue wait_queue_t;
typedef int (*wait_queue_func_t)(wait_queue_t *wait, unsigned mode, int flags, void *key);
struct __wait_queue {
unsigned int flags;
void *private;
wait_queue_func_t func;
struct list_head task_list;
};
通过DECLARE_WAITQUEUE()宏创建一个等待队列元素:
// [include/linux/wait.h]
#define __WAITQUEUE_INITIALIZER(name, tsk) { \
.private = tsk, \
.func = default_wake_function, \
.task_list = { NULL, NULL } }
#define DECLARE_WAITQUEUE(name, tsk) \
wait_queue_t name = __WAITQUEUE_INITIALIZER(name, tsk) // 创造一个元素
可以这么用:
DECLARE_WAITQUEUE(my_wait_queue_elt, current); //使用了current宏,申请了当前进程为等待元素
最后,一旦声明了一个等待队列元素,就可以通过add_wait_queue()函数将其加入到等待队列中。它基本上只是通过适当的加锁(暂时不用管)并将元素添加到双向链表中。
// [kernel/wait.c]
void add_wait_queue(wait_queue_head_t *q, wait_queue_t *wait)
{
unsigned long flags;
wait->flags &= ~WQ_FLAG_EXCLUSIVE;
spin_lock_irqsave(&q->lock, flags);
__add_wait_queue(q, wait);
spin_unlock_irqrestore(&q->lock, flags);
}
static inline void __add_wait_queue(wait_queue_head_t *head, wait_queue_t *new)
{
list_add(&new->task_list, &head->task_list);
}
阻塞任务
阻塞任务需要做两件事:
- 将任务的运行状态设置为TASK_INTERRUPTIBLE
- 调用deactivate_task()以移出运行队列
一般用schedule()替换deactivate_task()
schedule()函数是调度器的主要函数。调用schedule()时,必须选择下一个在CPU上运行的任务。也就是说,必须更新运行队列的curr字段。
但是,如果调用schedule()时当前任务状态并不是正在运行(即state字段不为0),并且没有信号挂起,则会调用deactivate_task()
asmlinkage void __sched schedule(void)
{
struct task_struct *prev, *next;
unsigned long *switch_count;
struct rq *rq;
int cpu;
// ... cut ...
prev = rq->curr; // 现在正在运行的任务
if (prev->state && !(preempt_count() & PREEMPT_ACTIVE)) { // <----- 忽视优先级
if (unlikely(signal_pending_state(prev->state, prev)))
prev->state = TASK_RUNNING;
else
deactivate_task(rq, prev, DEQUEUE_SLEEP); // 任务被移出运行队列
switch_count = &prev->nvcsw;
}
// ... cut (choose the next task) ...
}
最后,可以通过如下代码阻塞任务:
void make_it_block(void)
{
__set_current_state(TASK_INTERRUPTIBLE);
schedule();
}
任务将被阻塞,直到其他东西唤醒它。
唤醒任务
唤醒任务
1.阻塞的任务可以通过信号(和其他方式)唤醒
2.别的任务唤醒它
特定资源具有特定的等待队列。当任务想要访问此资源但此时不可用时,该任务可以使自己处于睡眠状态,直到资源所有者将其唤醒为止。
为了在资源可用时被唤醒,它必须将自己注册到该资源的等待队列。正如我们之前看到的,这个“注册”是通过add_wait_queue()调用完成的。
当资源可用时,所有者唤醒一个或多个任务,以便他们可以继续执行。这是通过__wake_up()函数完成的:
// [kernel/sched.c]
/**
* __wake_up - wake up threads blocked on a waitqueue.
* @q: the waitqueue
* @mode: which threads
* @nr_exclusive: how many wake-one or wake-many threads to wake up
* @key: is directly passed to the wakeup function
*
* It may be assumed that this function implies a write memory barrier before
* changing the task state if and only if any tasks are woken up.
*/
void __wake_up(wait_queue_head_t *q, unsigned int mode,
int nr_exclusive, void *key)
{
unsigned long flags;
spin_lock_irqsave(&q->lock, flags);
__wake_up_common(q, mode, nr_exclusive, 0, key); // <----- here
spin_unlock_irqrestore(&q->lock, flags);
}
// [kernel/sched.c]
static void __wake_up_common(wait_queue_head_t *q, unsigned int mode,
int nr_exclusive, int wake_flags, void *key)
{
wait_queue_t *curr, *next;
[0] list_for_each_entry_safe(curr, next, &q->task_list, task_list) {
unsigned flags = curr->flags;
[1] if (curr->func(curr, mode, wake_flags, key) &&
(flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
break;
}
}
此函数迭代等待队列中的每个元素[0](list_for_each_entry_safe()是与双链表一起使用的宏)。对每个元素都调用func()回调函数[1]。注意回调函数在DECLARE_WAITQUEUE()宏中被设置为default_wake_function。
// [include/linux/wait.h]
#define __WAITQUEUE_INITIALIZER(name, tsk) { \
.private = tsk, \
.func = default_wake_function, \ // <------
.task_list = { NULL, NULL } }
#define DECLARE_WAITQUEUE(name, tsk) \
wait_queue_t name = __WAITQUEUE_INITIALIZER(name, tsk)
default_wake_function()将等待队列元素的private字段(在大多数情况下指向睡眠任务的task_struct)作为参数调用try_to_wake_up():
int default_wake_function(wait_queue_t *curr, unsigned mode, int wake_flags,
void *key)
{
return try_to_wake_up(curr->private, mode, wake_flags);
}
最后,try_to_wake_up()有点像schedule()的“对立面”。schedule()将当前任务“调度出去”,try_to_wake_up()使其再次可调度。也就是说,它将任务加入运行队列中并更改其状态为"TASK_RUNNING"!
static int try_to_wake_up(struct task_struct *p, unsigned int state,
int wake_flags)
{
struct rq *rq;
// ... cut (find the appropriate run queue) ...
out_activate:
schedstat_inc(p, se.nr_wakeups); // <----- 更新一些数据
if (wake_flags & WF_SYNC)
schedstat_inc(p, se.nr_wakeups_sync);
if (orig_cpu != cpu)
schedstat_inc(p, se.nr_wakeups_migrate);
if (cpu == this_cpu)
schedstat_inc(p, se.nr_wakeups_local);
else
schedstat_inc(p, se.nr_wakeups_remote);
activate_task(rq, p, en_flags); // 放回运行队列
success = 1;
p->state = TASK_RUNNING; // state改变
// ... cut ...
}
这里调用了activate_task()。因为任务现在回到运行队列中并且其状态为TASK_RUNNING,所以它有可能会被调度,回到之前调用schedule()中断的地方继续执行。
实际上很少直接调用__wake_up()。通常会调用这些辅助宏:
// [include/linux/wait.h]
#define wake_up(x) __wake_up(x, TASK_NORMAL, 1, NULL)
#define wake_up_nr(x, nr) __wake_up(x, TASK_NORMAL, nr, NULL)
#define wake_up_all(x) __wake_up(x, TASK_NORMAL, 0, NULL)
#define wake_up_interruptible(x) __wake_up(x, TASK_INTERRUPTIBLE, 1, NULL)
#define wake_up_interruptible_nr(x, nr) __wake_up(x, TASK_INTERRUPTIBLE, nr, NULL)
#define wake_up_interruptible_all(x) __wake_up(x, TASK_INTERRUPTIBLE, 0, NULL)
具体唤醒和阻塞实例
struct resource_a {
bool resource_is_ready;
wait_queue_head_t wq;
};
void task_0_wants_resource_a(struct resource_a *res)
{
if (!res->resource_is_ready) {
// "register" to be woken up
DECLARE_WAITQUEUE(task0_wait_element, current);
add_wait_queue(&res->wq, &task0_wait_element);
// start sleeping
__set_current_state(TASK_INTERRUPTIBLE);
schedule();
// We'll restart HERE once woken up
// Remember to "unregister" from wait queue
}
// XXX: ... do something with the resource ...
}
void task_1_makes_resource_available(struct resource_a *res)
{
res->resource_is_ready = true;
wake_up_interruptible_all(&res->wq); // <--- unblock "task 0"
}
一个线程运行task_0_wants_resource_a()函数,该线程因“资源”不可用而阻塞。在晚些时候,资源所有者(来自另一个线程)使资源可用并调用task_1_makes_resource_available()之后,task_0_wants_resource_a()可以恢复继续执行。
这是在Linux内核代码中经常可以看到的模式。注意,“资源”在这里是一个泛指。任务可以等待某个事件,某个条件为真或其他东西。
0x02 文件子系统
文件描述符->文件描述符表->文件对象
文件对象
在Linux内核中,有七种基本文件:常规,目录,链接,字符设备,块设备,fifo和socket。它们中的每一个都可以由文件描述符表示。文件描述符基本上是一个仅对给定进程有意义的整数。对于每个文件描述符,都有一个关联的结构体:struct file。
file结构体(或文件对象)表示已打开的文件。它不一定匹配磁盘上的任何内容。例如,考虑访问像/proc这样的伪文件系统中的文件。在读取文件时,系统可能需要跟踪当前文件读取的位置。这是存储在file结构体中的一种信息。指向file结构体的指针通常被命名为filp(file pointer)。
ps:/proc可以看https://blog.csdn.net/zdwzzu2006/article/details/7747977
部分结构体:
// [include/linux/fs.h]
struct file {
loff_t f_pos; // 读文件是的游标
atomic_long_t f_count; // 对象引用计数器
const struct file_operations *f_op; // 虚函数表指针
void *private_data; // 专门被文件使用的(就是指向struct socket的)
// ...
};
文件描述符表
将文件描述符转换为file结构体指针的映射关系被称为文件描述符表(fdt)。 请注意,这不是1对1映射,可能有多个文件描述符指向同一个文件对象。在这种情况下,指向的文件对象的引用计数增加1(参见Reference Counters)。FDT存储在一个名为struct fdtable的结构体中。这实际上只是一个file结构体指针数组,可以使用文件描述符进行索引。
部分结构体:
// [include/linux/fdtable.h]
struct fdtable {
unsigned int max_fds;
struct file ** fd; /* current fd array */
// ...
};
文件描述符
将文件描述符表与进程关联起来的是struct files_struct。 fdtable没有直接嵌入到task_struct中的原因是它有其他信息。一个files_struct结构体也可以在多个线程(即task_struct)之间共享,并且还有一些优化技巧。
部分结构体:
// [include/linux/fdtable.h]
struct files_struct {
atomic_t count; // 计数器
struct fdtable *fdt; // 指向文件描述符表
// ...
};
dup()系统调用
两个文件描述符指向相同的文件对象,防止当前文件描述符被关闭。
0x03 虚表
虽然Linux主要由C实现,但Linux仍然是面向对象的内核。实现某种通用性的一种方法是使用虚函数表(vft)。 虚函数表是一种主要由函数指针组成的结构。
以struct file_operations为例子。。。
部分结构体:
// [include/linux/fs.h]
struct file_operations {
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
int (*open) (struct inode *, struct file *);
int (*release) (struct inode *, struct file *);
// ...
};
虽然一切都是文件但不是同一类型,因此它们都有各自不同的文件操作,通常称为f_ops。 这样做允许内核代码独立于其类型和代码分解(code factorization,不知道具体应该如何翻译)来处理文件。它导致了这样的代码:
if (file->f_op->read)
ret = file->f_op->read(file, buf, count, pos);
0x04 Socket和Sock
基本信息
ps:struct sock对象通常称为sk,而struct socket对象通常称为sock。
struct socket位于网络堆栈的顶层。从文件的角度来看,这是第一级特殊化。在套接字创建期间(socket()syscall),将创建一个新的file结构体,并将其文件操作(filed f_op)设置为socket_file_ops(虚函数表)。
由于每个文件都用文件描述符表示,因此你可以用套接字文件描述符作为参数来调用任何以文件描述符作为参数的系统调用(例如read(),write(),close())。 这实际上是“一切都是文件”座右铭的主要好处。独立于套接字的类型,内核将调用通用套接字文件操作:
// [net/socket.c]
static const struct file_operations socket_file_ops = {
.read = sock_aio_read, // <---- calls sock->ops->recvmsg()
.write = sock_aio_write, // <---- calls sock->ops->sendmsg()
.llseek = no_llseek, // <---- returns an error
// ...
}
由于struct socket实际上实现了BSD socket API(connect(),bind(),accept(),listen(),…),因此它们嵌入了一个类型为struct proto_ops的特殊虚函数表(vft)。每种类型的套接字(例如AF_INET,AF_NETLINK)都实现自己的proto_ops。
// [include/linux/net.h]
struct proto_ops {
int (*bind) (struct socket *sock, struct sockaddr *myaddr, int sockaddr_len);
int (*connect) (struct socket *sock, struct sockaddr *vaddr, int sockaddr_len, int flags);
int (*accept) (struct socket *sock, struct socket *newsock, int flags);
// ...
}
当调用BSD类型的系统调用(例如bind())时,内核通常遵循下列过程:
从文件描述符表中获得file结构体指针
从file结构体中获得socket结构体指针
调用专门的proto_ops回调函数(例如sock-> ops-> bind())
由于某些协议操作(例如发送/接收数据)可能实际上需要进入网络堆栈的较低层,因此struct socket具有指向struct sock对象的指针。该指针通常由套接字协议操作(proto_ops)使用。最后,struct socket是struct file和struct sock之间的一种粘合剂。
// [include/linux/net.h]
struct socket {
struct file *file;
struct sock *sk;
const struct proto_ops *ops;
// ...
};
struct sock是一个复杂的数据结构。人们可能会将其视为下层(网卡驱动程序)和更高级别(套接字)之间的中间事物。其主要目的是能够以通用方式保持接收/发送缓冲区。
当通过网卡接收到数据包时,驱动程序将网络数据包“加入”到sock接收缓冲区中。它会一直存在,直到程序决定接收它(recvmsg()系统调用)。反过来,当程序想要发送数据(sendmsg()系统调用)时,网络数据包被“加入”到sock发送缓冲区。一有机会,网卡将“取出”该数据包并发送。
那些“网络数据包”就是所谓的struct sk_buff(或skb)。接收/发送缓冲区基本上是一个skb双向链表:
// [include/linux/sock.h]
struct sock {
int sk_rcvbuf; // 理论上最大的接收缓冲区
int sk_sndbuf; // 理论上最大的发送缓存区
atomic_t sk_rmem_alloc; // 当前接收缓冲区的大小
atomic_t sk_wmem_alloc; // 当前发送缓冲区的大小
struct sk_buff_head sk_receive_queue; // 接收双链表的头
struct sk_buff_head sk_write_queue; // 发送双链表的头
struct socket *sk_socket;
// ...
}
可以看到,struct sock引用了struct socket(filed sk_socket),而struct socket引用了struct sock(field sk)。 同样,struct socket引用struct file(field file),而struct file引用struct socket(field private_data)。这种“双向机制”允许数据在网络堆栈中上下移动。
Netlink Socket(sock实例)
参考:https://blog.csdn.net/zhao_h/article/details/80943226
Netlink socket是一类套接字,类似于UNIX或INET套接字。 Netlink套接字是用以实现用户进程与内核进程通信的一种特殊的进程间通信(IPC) ,也是网络应用程序与内核通信的最常用的接口。
Netlink套接字(AF_NETLINK)允许内核和用户空间之间的通信。 它可用于修改路由表(NETLINK_ROUTE协议),接收SELinux事件通知(NETLINK_SELINUX)甚至与其他用户进程通信(NETLINK_USERSOCK)。
由于struct sock和struct socket是支持各种套接字的通用数据结构,因此有必要在某种程度上“实例化”。
Netlink 相对于系统调用,ioctl 以及 /proc文件系统而言具有以下优点:
- netlink使用简单,只需要在include/linux/netlink.h中增加一个新类型的 netlink 协议定义即可,(如 #define NETLINK_TEST 20 然后,内核和用户态应用就可以立即通过 socket API 使用该 netlink 协议类型进行数据交换);
- netlink是一种异步通信机制,在内核与用户态应用之间传递的消息保存在socket缓存队列中,发送消息只是把消息保存在接收者的socket的接收队列,而不需要等待接收者收到消息;
- 使用 netlink 的内核部分可以采用模块的方式实现,使用 netlink 的应用部分和内核部分没有编译时依赖;
- netlink 支持多播,内核模块或应用可以把消息多播给一个netlink组,属于该neilink 组的任何内核模块或应用都能接收到该消息,内核事件向用户态的通知机制就使用了这一特性;
- 内核可以使用 netlink 首先发起会话;
从套接字的角度来看,需要定义proto_ops字段。 对于netlink系列(AF_NETLINK),BSD样式的套接字操作是netlink_ops:
// [net/netlink/af_netlink.c]
static const struct proto_ops netlink_ops = {
.bind = netlink_bind,
.accept = sock_no_accept, // <--- calling accept() on netlink sockets leads to EOPNOTSUPP error
.sendmsg = netlink_sendmsg,
.recvmsg = netlink_recvmsg,
// ...
}
在netlink的情况下,其实就类似实例化的类,或者说sock的一种扩展,使用struct netlink_sock:
// [include/net/netlink_sock.h]
struct netlink_sock {
/* struct sock是netlink_sock的第一个字段 */
struct sock sk;
u32 pid;
u32 dst_pid;
u32 dst_group;
u32 flags;
u32 subscriptions;
u32 ngroups;
unsigned long *groups;
unsigned long state;
wait_queue_head_t wait;
struct netlink_callback *cb;
struct mutex *cb_mutex;
struct mutex cb_def_mutex;
void (*netlink_rcv)(struct sk_buff *skb);
struct module *module;
};
struct sock在struct netlink_sock的第一个字段,意味着&netlink_sock.sk和&netlink_sock地址是一样的,释放指针&netlink_sock.sk实际上释放了整个netlink_sock对象。还有这允许了允许内核在不知道其精确类型的情况下操作通用sock结构体。
Netlink有一个全局数组nl_table,类型为netlink_table:
// [net/netlink/af_netlink.c]
struct netlink_table {
struct nl_pid_hash hash; // 存放netlink对象的哈希表
struct hlist_head mc_list;
unsigned long *listeners;
unsigned int nl_nonroot;
unsigned int groups;
struct mutex *cb_mutex;
struct module *module;
int registered;
};
static struct netlink_table *nl_table;
nl_table数组会在开机时被netlink_proto_init()初始化:
// [include/linux/netlink.h]
#define NETLINK_ROUTE 0 /* Routing/device hook */
#define NETLINK_UNUSED 1 /* Unused number */
#define NETLINK_USERSOCK 2 /* Reserved for user mode socket protocols */
// ... cut ...
#define MAX_LINKS 32
// [net/netlink/af_netlink.c]
static int __init netlink_proto_init(void)
{
// ... cut ...
nl_table = kcalloc(MAX_LINKS, sizeof(*nl_table), GFP_KERNEL);
// ... cut ...
}
换句话说,每一个协议一个netlink_table(NETLINK_USERSOCK时其中一个)。此外,每个netlink tables嵌入一个数据类型为nl_pid_hash的hash段:
// [net/netlink/af_netlink.c]
struct nl_pid_hash {
struct hlist_head *table;
unsigned long rehash_time;
unsigned int mask;
unsigned int shift;
unsigned int entries;
unsigned int max_shift;
u32 rnd;
};
这个结构是用来操作netlink哈希表。这意味着要用以下字段:
- table:一个struct hlist_head的数组,真正的哈希表
- reshash_time:减少每一段时间“稀释(dilution)”的次数。??
- mask: buckets 的数量(减一),屏蔽哈希函数的结果
- shift::一些位数的order,用来计算元素的平均数量(负载因子)。顺便表示表已增长时间。
- entries:哈希表中的元素总数量。
- max_shift: 一些位数的order。表最大的增长时间,即buckets的最大数量。
- rnd: 哈希函数使用的一个随机数字
netlink哈希表介绍
让我们来看一下至今为止,我们对netlink哈希表了解什么:
- netlink每个协议都有一个哈希表
- 每一个哈希表开始都有一个单一的bucket
- 每个bucket平均有两个元素
- 表会在可能出现每个bucket(可能)超过两个元素的情况下增长。
- 每次一个哈希表增长,是以bucket乘以2的速度增长的。
- 当一个元素插入到一个bucket会出现不平衡的情况,就会出现“稀释”。
- 元素总是从bucket的头部插入的
- 当一次稀释发生时,哈希函数就改变了。
- 哈希函数使用一个用户提供的pid和一个不可控的键值。
- 哈希函数被设定为不可逆的,所以我们无法控制元素必须插入某一个bucket。
- 任何的哈希表操作都被一个全局锁保护(netlink_table_grab() 和 netlink_table_ungrab())。
还有一些关于移除元素(查看netlink_remove())
- 哈希表被扩展后,不可缩小。
- 移除操作不会触发稀释
0x05 sock的发送和接收
https://blog.csdn.net/shisiye15/article/details/7762606
https://blog.csdn.net/yuanfengyun/article/details/50487475
接收函数:https://blog.csdn.net/zhangge3663/article/details/83827174
关于结构体struct msghdr详细见:https://blog.csdn.net/ccccdddxxx/article/details/6368337
函数
#include <sys/types.h>
#include <sys/socket.h>
ssize_t send (int sockfd, const void *msg, size_t len, int flags);
ssize_t recv(int sockfd, void * buf, size_t nbytes, int flags);
//返回值:以字节计数的消息长度,若无可用消息或对方已经按序结束则返回0,出错返回-1。
ssize_t sendto (int sockfd, const void *msg, size_t len, int flags, const struct sockaddr *to,socklen_t tolen);
ssize_t recvfrom(int sockfd,void * buf,size_t len,int flags,struct sockaddr * addr, socklen_t * addrlen);
//返回值:以字节计数的消息长度,若无可用消息或对方已经按序结束则返回0,出错返回-1。
ssize_t sendmsg (int sockfd, const struct msghdr *msg, int flags);
ssize_t recvmsg(int sockfd, struct msghdr * msg, int flag);
//返回值:以字节计数的消息长度,若无可用消息或对方已经按序结束则返回0,出错返回-1。
发送接收(结构体,注意netlink的特殊性)
struct msghdr类似报文头,反正介绍上面那些函数需要用:
struct msghdr {
void *msg_name; /* 一般是通信目的地址(netlink的地址编码是struct sockaddr_nl) */
socklen_t msg_namelen; /* 通信地址的长度 */
struct iovec *msg_iov; /* 结构体strcut iovec的数组 */
size_t msg_iovlen; /* # msg_iov中有多少数据包(buf) */
void *msg_control; /* 辅助数据缓冲区 */
size_t msg_controllen; /* 辅助缓冲区大小*/
int msg_flags; /* 接收信息标记位 */
};
msg_name和msg_namelen:
//一般情况:
strcut sockaddr_in{
sa_family_t sin_family;
unsigned short int sin_port;
struct in_addr sin_addr;
unsigned char __pad[__SOCK_SIZE__ - sizeof(short int) -
sizeof(unsigned short int) - sizeof(struct in_addr)];
};
struct in_addr{
__u32 s_addr;
}
//进行参数传递的时候:
struct sockaddr{
sa_family_t sa_family;
char sa_addr[14];
}
//netlink套接字特殊:
struct sockaddr_nl
{
sa_family_t nl_family; //例如AF_NETLINK这种。。。
unsigned short nl_pad; //“0”
__u32 nl_pid; //端口号
__u32 nl_groups; //广播组掩码,不成为广播组一部分可以直接写0
};
msg_iov和msg_iovlen:注意允许发送多个buff。。。
struct iovec
{
void __user *iov_base; //数据包缓存区,参数buff
__kernel_size_t iov_len; //数据包缓存区的长度
};
msg_flags:即函数传入的参数flag
send():
0: 与write()无异
MSG_DONTROUTE:告诉内核,目标主机在本地网络,不用查路由表
MSG_DONTWAIT:将单个I/O操作设置为非阻塞模式
MSG_OOB:指明发送的是带外信息
recv():
0:常规操作,与read()相同
MSG_DONTWAIT:将单个I/O操作设置为非阻塞模式
MSG_OOB:指明发送的是带外信息
MSG_PEEK:可以查看可读的信息,在接收数据后不会将这些数据丢失
MSG_WAITALL:通知内核直到读到请求的数据字节数时,才返回。
recvfrom():
0:常规操作,与read()相同
MSG_OOB:指明发送的是带外信息
MSG_PEEK:可以查看可读的信息,在接收数据后不会将这些数据丢失
sendto():
0: 与write()无异
MSG_DONTROUTE:告诉内核,目标主机在本地网络,不用查路由表
MSG_OOB:指明发送的是带外信息
sendmsg:
不适用直接写0
recvmsg:
MSG_EOR:当接收到记录结尾时会设置这一位。这通常对于SOCK_SEQPACKET套接口类型十分有用。
MSG_TRUNC:这个标记位表明数据的结尾被截短,因为接收缓冲区太小不足以接收全部的数据。
MSG_CTRUNC:这个标记位表明某些控制数据(附属数据)被截短,因为缓冲区太小。
MSG_OOB:这个标记位表明接收了带外数据。
MSG_ERRQUEUE:这个标记位表明没有接收到数据,但是返回一个扩展错误。
具体sock的发送接收(Netlink为例)
为了避免被包装,所用的系统调用函数需要宏定义
#define _socket(domain, type, protocol) syscall(__NR_socket, domain, type, protocol)
#define _sendmsg(sockfd, msg, flags) syscall(__NR_sendmsg, sockfd, msg, flags)
#define _recvmsg(sockfd, msg, flags) syscall(__NR_recvmsg, sockfd, msg, flags)
#define _bind(sockfd, addr, addrlen) syscall(__NR_bind, sockfd, addr, addrlen)
socket()的具体函数解析可以参照:https://blog.csdn.net/liuxingen/article/details/44995467
以netlinksock为例子:
#include <sys/stat.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <string.h>
#include <asm/types.h>
#include <linux/netlink.h>
#include <linux/socket.h>
#include <errno.h>
#define MAX_PAYLOAD 1024 //payload最大的限制为1024
int sock_fd,retval,state;
struct sockaddr_nl addr={
.nl_family = AF_NETLINK,
.nl_pad = 0,
.nl_pid = 0, // 为0时一般是内核通信,使用进程的pid,就是向相应的进程发送
.nl_groups = 0 // 不加入广播组
};
struct nlmsghdr *nlh = NULL; //Netlink数据包头
struct iovec iov;
struct msghdr msg;
iov.iov_base=nlh;
iov.iov_len=NLMSG_SPACE(MAX_PAYLOAD); //NLMSG_SPACE()是linux/netlink.h定义的宏
//msghdr
msg.msg_name = &addr,
msg.msg_namelen = sizeof(addr),
msg.msg_iov = &iov,
msg.msg_iovlen = 1,
msg.msg_control = NULL,
msg.msg_controllen = 0,
msg.msg_flags = 0,
sock_fd = _socket(AF_NETLINK, SOCK_RAW, NETLINK_TEST); //创建套接字
retval=_bind(sock_fd,(struct sockaddr*)&src_addr,sizeof(src_addr)); //绑定套接字和相应的地址
state=_sendmsg(sock_fd,&msg,0); //向msg.msg_name->nl_pid发送信息
state=recvmsg(sock_fd,&msg,0); //接收msg.msg_name->nl_pid发送的数据
0x06 linux中的引用计数
为了减少内核中的内存泄漏并防止释放后重用,大多数Linux数据结构都嵌入了“ref counter”。 refcounter本身用atomic_t类型表示,该类型基本上是整数。 refcounter只能通过原子操作来操作,例如:
atomic_inc()
atomic_add()
atomic_dec_and_test()//减去1并测试它是否等于零
计数操作必须由开发人员手动完成。当一个对象被另一个对象引用时,必须明确增加其refcounter。删除此引用时,必须明确减少refcounter。当refchounter为零时,通常会释放该对象。
增加refcounter通常称为“引用”,而减少refcounter称为“删除/释放引用”。
Linux内核有几个函数来处理具有通用接口的refcounters(kref,kobject)。但是,它并没有被系统地使用,我们将操作的对象有自己的引用计数处理过程。一般来说,主要由“_get()”系列函数进行引用,而“_put()”系列函数进行释放。
在我们的例子中,每个对象都有不同的处理过程名称:
struct sock:sock_hold(),sock_put()
struct file:fget(),fput()
struct files_struct:get_files_struct(),put_files_struct()
ps:skb_put()实际上不会减少任何refcounter,它只会将数据“推送”到sk缓冲区!不要基于其名称假设函数做什么,直接看代码。
0x07 linux内存相关(内存子系统)
https://blog.csdn.net/YuZhiHui_No1/article/details/47305361
基本的分配函数
具体的可以看这:https://www.cnblogs.com/sky-heaven/p/7390370.html
void *kmalloc(size_t size, gfp_t flags);
void *kzalloc(size_t size, gfp_t flags); //和kmalloc类似,多了一个清零的标志位
void kfree(const void *objp);
kmalloc() 申请的内存位于物理内存映射区域,而且在物理上也是连续的,它们与真实的物理地址只有一个固定的偏移,因为存在较简单的转换关系,所以对申请的内存大小有限制,不能超过128KB。slub用的方式,也是大多数时候的分配方式。
void *vmalloc(unsigned long size);
void vfree(const void *addr);
vmalloc() 函数则会在虚拟内存空间给出一块连续的内存区,但这片连续的虚拟内存在物理内存中并不一定连续。由于 vmalloc() 没有保证申请到的是连续的物理内存,因此对申请的内存大小没有限制,如果需要申请较大的内存空间就需要用此函数了。
物理页管理
因为效率原因,物理内存把相邻的内存分为固定长度块。这个块叫做一个页框,有一个固定的4096位大小。它可以用PAGE_SIZE 宏检索到。
因为内核必须控制内存。所以它保持着每一个物理页框的追踪像他们的信息。举个例子,他们必须知道特定的页面是不是可用的,这些信息被记录在页面数据结构struct page(也被叫做页面描述符)。
内核可以用alloc_pages()申请一个或者多个相邻的页面,用free_pages()来释放他们。分区页框分配器用来管理内核的这些请求,通常使用的伙伴系统算法。所以也被叫做伙伴伙伴分配器。
基本概念
以slab分配器为例:
基本结构(图是别人的。。。):
在系统初始化时,就是linux会创建以2的n次方为大小,依次创建很多通用内存。在SLAB中,通用缓存会有前缀"size-"(size-32,size-64)。在SLUB中,通用缓存会有前缀"kmalloc-"(kmalloc-32)。这些通用内存就是struct kmem_cache(缓存描述符)即一个cache,刚开始这些都是空的。
struct kmem_cache {
// ...
unsigned int num; // 每个slab中的对象数量
unsigned int gfporder; // 一个slab对象包含连续页是2的几次方
const char *name; // 这个cache的名字
int obj_size; // 管理的对象的大小
struct kmem_list3 **nodelists; // 维护三个链表empty/partial/full slabs
struct array_cache *array[NR_CPUS]; // 每个cpu中空闲对象组成的数组
};
cache为了防止频繁使用伙伴分配器,所以自己维持了三个链表empty/partial/full slabs,把临时释放的内存存在三个链表中,要用的时候可以取出来。每个链表中存的是slab,以kmalloc-1024为例,一个slab只有一页(假设是64为操作系统,所以是4kb)大小的话,一个slab就4个对象,这个slab会根据内部对象有没有被用完,在三个链表中切换。
struct slab {
struct list_head list; // 用于将slab链入kmem_list3的链表
unsigned long colouroff; // 该slab的着色偏移
void *s_mem; // 指向slab中的第一个对象
unsigned int inuse; // 已经分配对象的数量
kmem_bufctl_t free; // 下一个未分配对象的下标
unsigned short nodeid; // 节点标识号
};
注意还有个struct array_cache在缓存描述符中,用来记录当前cpu中的空闲对象,每颗cpu有一个这种数组,是为了不经常遍历empty/partial链表,更快分配内存。
struct array_cache {
unsigned int avail; // 存放可用对象指针的数量也是当前空闲空闲数组的下标
unsigned int limit; // 最多可以存放的对象指针数量
unsigned int batchcount;
unsigned int touched;
spinlock_t lock;
void *entry[]; // 对象指针数组
};
在内存被申请后,内核首先会根据申请的大小,找到相应的cache,遍历在cahce中找到空闲的对象来分配给进程。如果没有空闲的对象,需要创建一个新的slab,Slab分配器会向伙伴分配器申请相应的物理页,然后把得到的slab中的一个对象分配。
同样的,当cache三个链表的中的空闲slab过多时,会销毁slab,把相应的物理页内存还给伙伴分配器。
最简单的申请内存:
static inline void *____cache_alloc(struct kmem_cache *cachep, gfp_t flags) // yes... four "_"
{
void *objp;
struct array_cache *ac;
ac = cpu_cache_get(cachep);
if (likely(ac->avail)) {
STATS_INC_ALLOCHIT(cachep);
ac->touched = 1;
objp = ac->entry[--ac->avail]; // <-----
}
// ... cut ...
return objp;
}
最简单的释放内存:
static inline void __cache_free(struct kmem_cache *cachep, void *objp)
{
struct array_cache *ac = cpu_cache_get(cachep);
// ... cut ...
if (likely(ac->avail < ac->limit)) {
STATS_INC_FREEHIT(cachep);
ac->entry[ac->avail++] = objp; // <-----
return;
}
}
伙伴分配器:分配2^n次方数量的物理页,最少也是一个物理页大小。
slab分配器:假设用来维护一个cahce的slab的各个操作,分配小块内存。注意slab分配器采用的时LIFO算法。
0x08 虚拟地址表(用户和内核空间虚拟地址范围)
我们看到“最高”的虚拟用户空间地址是:
#define TASK_SIZE_MAX ((1UL << 47) - PAGE_SIZE) // == 0x00007ffffffff000
ps:1左移47位,就是0x1000000000000,减去0x1000(页大小),就是0x7ffffffff000。
有人可能想知道“47”是怎么来的?
在早期的amd64位构架中,设计师认为2^64的内存不知道为啥检索太大了,强迫加入了另一种页表等级(性能损失)。因为这个原因,他被确定只有低48位的地址可以从虚拟地址转化为物理地址。
无论如何,如果用户空间在0x0000000000000000 和0x00007ffffffff000之间,那么内核地址呢?答案是0xffff800000000000 到 0xffffffffffffffff。也就是说看到一个地址开始于“0xffff8*”或者更高,你应该明白是内核地址。
即,48到63位是:
- 清空所有用户地址
- 设置所有内核地址
具体来说,AMD强制要求[48:63]是和47位是一样。否则,会抛出一个错误。关于这个地址的习俗叫做规范型地址(canonical form addresses)。在这个规范下,他仍然有256TB的内存(一半给用户,一半给内核)。
在0x00007ffffffff000 和 0xffff800000000000之间的空间是未使用内存地址unused memory addresses(也叫做non-canonical addresses)。即64位下的虚拟内存地址布局流程:
上述的图是一个大概的图。你可以在linux内核文档中获得更准确的图:Documentation/x86/x86_64/mm.txt。
0x09 内核线程栈
在linux(x86-64位架构下),有两种内核栈:
- 线程栈:16k-bit 栈,用于每一个活动线程
- 专用栈:一组位于每个cpu的特殊操作栈
你可能想要去读linux内核文档为了附加/互补的信息:Documentation/x86/x86_64/kernel-stacks。
首先,让我们描述一下线程栈。当一个新的线程被创建(注:一个新的task_struct),内核用copy_process()做了一个“fork-like”操作。后者分配一个新的task_struct(记住,每个线程都有一个task_struct),把大部分父母进程的task_struct拷贝到了新的。
无论如何,取决于怎么创建task,一些资源会被共享或者复制(注:内存在一个多线程程序中是被共享的。libc的数据会被复制)。在下面的例子中,如果线程修改一些数据,那么一个新的独立版本会被创建:这被叫做copy-on-write(注:他只影响当前的线程导入libc,不会影每一个线程)。
换句话说,一个进程不会从头开始创建,往往是从父母进程的拷贝开始的(在init里)。
此外,还有一些线程的特定数据,其中一个是内核线程栈。在整一个创建和复制进程的过程中,dup_task_struct()很早就被调用:
static struct task_struct *dup_task_struct(struct task_struct *orig)
{
struct task_struct *tsk;
struct thread_info *ti;
unsigned long *stackend;
int node = tsk_fork_get_node(orig);
int err;
prepare_to_copy(orig);
[0] tsk = alloc_task_struct_node(node);
if (!tsk)
return NULL;
[1] ti = alloc_thread_info_node(tsk, node);
if (!ti) {
free_task_struct(tsk);
return NULL;
}
[2] err = arch_dup_task_struct(tsk, orig);
if (err)
goto out;
[3] tsk->stack = ti;
// ... cut ...
[4] setup_thread_stack(tsk, orig);
// ... cut ...
}
#define THREAD_ORDER 2
#define alloc_thread_info_node(tsk, node) \
({ \
struct page *page = alloc_pages_node(node, THREAD_FLAGS, \
THREAD_ORDER); \
struct thread_info *ret = page ? page_address(page) : NULL; \
\
ret; \
})
上面的代码做了这么些事:
[0]:用Slab分配器分配一个新的struct task_struct
[1]:用伙伴分配器分配一个新的线程栈
[2]:把orig(初始的?) task_struct拷贝到一个tsk task_struct(不同下面会谈到)
[3]:改变task_struct的stack参数指向ti,新的线程现在又他的专有线程栈和他自己的thread_info
[4]:把orig’s(初始的??) thread_info的内容拷贝到新的tsk’s thread_info,修复task字段
注意[1]中应该申请struct thread_info,但是他申请了线程栈,实际上struct thread_info和线程栈开始的位置相同。可以看上面关于thread_info的描述:
#define THREAD_SIZE (PAGE_SIZE << THREAD_ORDER)
union thread_union { // <----- this is an "union"
struct thread_info thread_info;
unsigned long stack[THREAD_SIZE/sizeof(long)]; // <----- 16k-bytes
};
除了init程序,thread_union不再被使用(再x86-64位下)但是布局仍然一样:
STACK_END_MAGIC为了减轻内核线程栈溢出。在很早之前就解释过,重写thread_info的数据可以导致可怕的事情(在restart_block 段中,他也有函数指针)。
因为thread_info是在这个区间的最高点,希望你现在理解为什么,通过屏蔽掉THREAD_SIZE,你可以从任何内核线程栈指针中检索thread_info的地址
在上面的图中,你可能会注意到kernel_stack 指针。这是一个每个cpu都有一个的变量。申明在这里:
// [arch/x86/kernel/cpu/common.c]
DEFINE_PER_CPU(unsigned long, kernel_stack) =
(unsigned long)&init_thread_union - KERNEL_STACK_OFFSET + THREAD_SIZE;
最开始,kernel_stack指向init线程栈(注:init_thread_union)。无论如何,在内容转换时,每一个cpu变量会更新:
#define task_stack_page(task) ((task)->stack)
__switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
// ... cut ..
percpu_write(kernel_stack,
(unsigned long)task_stack_page(next_p) +
THREAD_SIZE - KERNEL_STACK_OFFSET);
// ... cut ..
}
最后,现在的thread_info被重置为:
static inline struct thread_info *current_thread_info(void)
{
struct thread_info *ti;
ti = (void *)(percpu_read_stable(kernel_stack) +
KERNEL_STACK_OFFSET - THREAD_SIZE);
return ti;
}
kernel_stack指针在进入系统调用的时候被使用。他替换了当前用户空间rsp,在系统调用结束后,还原用户空间rsp。
0x0A linux权限问题
struct cred规定了linux的权限
struct cred {
atomic_t usage;
// ... cut ...
uid_t uid; /* real UID of the task */
gid_t gid; /* real GID of the task */
uid_t suid; /* saved UID of the task */
gid_t sgid; /* saved GID of the task */
uid_t euid; /* effective UID of the task */
gid_t egid; /* effective GID of the task */
uid_t fsuid; /* UID for VFS ops */
gid_t fsgid; /* GID for VFS ops */
unsigned securebits; /* SUID-less security management */
kernel_cap_t cap_inheritable; /* caps our children can inherit */
kernel_cap_t cap_permitted; /* caps we're permitted */
kernel_cap_t cap_effective; /* caps we can actually use */
kernel_cap_t cap_bset; /* capability bounding set */
// ... cut ...
#ifdef CONFIG_SECURITY
void *security; /* subjective LSM security */
#endif
// ... cut ...
};
每一个任务(task_struct)有两个struct creds:
struct task_struct {
// ... cut ...
const struct cred *real_cred; /* objective and real subjective task credentials (COW) */
const struct cred *cred; /* effective (overridable) subjective task
// ... cut ...
};
注意直接覆盖自己程序的cred结构是无法提升自己的权限的。即,别手动覆盖自己的cred表,尽量用系统本身的函数,即commit_creds()
int commit_creds(struct cred *new)
{
struct task_struct *task = current;
const struct cred *old = task->real_cred;
// ... cut ...
get_cred(new); // <---- take a reference
// ... cut ...
rcu_assign_pointer(task->real_cred, new);
rcu_assign_pointer(task->cred, new);
// ... cut ...
/* release the old obj and subj refs both */
put_cred(old); // <----- release previous references
put_cred(old);
return 0;
}
好的,但是他需要一个有效的struct cred 参数。所以,是时候去找到他的朋友了:prepare_kernel_cred():
struct cred *prepare_kernel_cred(struct task_struct *daemon)
{
const struct cred *old;
struct cred *new;
new = kmem_cache_alloc(cred_jar, GFP_KERNEL);
if (!new)
return NULL;
if (daemon)
old = get_task_cred(daemon);
else
old = get_cred(&init_cred); // <----- THIS!
validate_creds(old);
*new = *old; // <----- copy all fields
// ... cut ...
}
基本上,prepare_kernel_cred()做的是:部署一个新的struct cred和吧他填充进现在的。无论如何,如果参数是null,他会拷贝init 进程的cred,系统上最受特权的进程(他甚至在“root”里运行)!
也就是说为了提权的函数:
commit_cred(prepare_kernel_cred(NULL));
0x0B 获取linux函数地址的方法
本文以获取内核函数 sys_open()的地址为例。
- 从System.map文件中直接得到地址(被调试的内核):
$ grep sys_open /usr/src/linux/System.map
- 使用 nm 命令(提取出被调试的内核vmlinux之后,在调试机上):
$ nm vmlinuz | grep sys_open
- 从 /proc/kallsyms 文件获得地址(被调试的内核中,这个需要root权限):
$ cat /proc/kallsyms | grep sys_open
- 使用 kallsyms_lookup_name() 函数:是在kernel/kallsyms.c文件中定义的,要使用它必须启用CONFIG_KALLSYMS编译内核kallsyms_lookup_name()接受一个字符串格式内核函数名,返回那个内核函数的地址。
kallsyms_lookup_name("sys_open");