ARM64汇编0C - inlinehook

本文是ARM64汇编系列的完结篇,主要利用前面学过的知识做一个小实验

完整系列博客地址:https://www.lyldalek.top/article/arm

这里只讨论 ARM64 下的 inlinehook,做一个简单的demo,只是抛砖引玉,有兴趣了解更多细节的可以去查找资料,看开源项目。

ARM64 相比 ARM 的 inlinehook 要麻烦不少,因为有很多指令都没了,且无法直接访问 PC 寄存器。

ARM64处理器下是兼容ARM32指令集的,因此,ARM64处理器上可以运行ARM64,ARM32,Thumb-2(Thumb16+Thumb32)三套指令,但是这里我们只讨论 ARM64 模式。

流程图

inlinehook是实现对一个指定函数的hook,其大致流程如下图:

首先,需要替换目标函数的头部的指令,将pc的值修改为hook函数的地址,跳转过去执行。

对于被覆盖的指令,我们将其放入hook函数中执行,对应图中的红色部分,这里会遇到非常多的问题,有兴趣的自行查找资料。

hook函数执行完之后,需要将 pc 的值修改为覆盖指令的下一条指令地址,然后跳转过去执行。

在这些过程中,我们需要保证寄存器在执行hook函数前后的值是一样的,执行的覆盖指令也不能修改寄存器,除非先将寄存器的值储存起来。

方案设计

第一步——原程序插桩

设计思路是:首先插桩代码最基本的是要一个跳转功能。ARM64中PC不让直接读写,那我们怎么改变PC呢?通常程序跳转都有两类方法:相对寻址和直接寻址。

相对寻址是有距离限制的,由于我们的hook程序也是一个so,它的位置是不固定的,且目标so的位置也是不固定的,所以这两个so的距离也是不固定的,使用相对寻址很不明智。

直接寻址,ARM64中有BR X??可以直接把程序跳转到X??寄存器中存储的64位地址上。那么,这时候的方案就应该是:

LDR X0, [TARGET_ADDRESS] 
BR X0

但是,这样会破坏X0中原本的值,所以,我们需要储存一些 X0 的值:

STP X1, X0, [SP, #-0x10]
LDR X0, [TARGET_ADDRESS]
BR X0

上面的第一个指令就是把X1,X0保存到栈上,这里的X1当然是多余的,纯属是为了满足ARM64上栈要16字节对齐且没有PUSH指令可用的约束。

既然,我们改了栈,那么就需要平衡栈,所以,那最后跳转回原程序时,我们需要将栈的值修改回来:

STP X1, X0, [SP, #-0x10]
LDR X0, [TARGET_ADDRESS]     ; TARGET_ADDRESS 是一个动态值,编译后会需要更多的指令来实现
BR X0
LDR X0, [SP, -0x8]

这里,就与我们设想的流程稍微有点区别,我们需要等hook函数执行完之后,跳转到上面的最后一行指令执行,恢复 X0 寄存器的值。

这里有个需要注意的地方,就是我们的插桩指令至少有4条,占据了16字节,如果函数体很小的话,那么hook就会导致错误。所以这里是一个优化点。

第二步——hook程序

我们在这里需要先保存所有寄存器的值,保存了寄存器的值之后,就可以使用这些寄存器了,保存结构如下:

对应的指令如下:

sub     sp, sp, #0x20          ;sp = sp - 0x20,也就是指针向上移动2格mrs     x0, NZCV               ;将状态寄存器的值储存到 x0
str     x0, [sp, #0x10]        ;将x0(状态寄存器)的值储存到 sp + 0x10 处,也就是图中的 PSR
str     x30, [sp]              ;将x30的值储存到 sp 处,图中的X30 LR
add     x30, sp, #0x20         ;X30 = sp + 0x20
str     x30, [sp, #0x8]        ;将 sp + 0x20 的值储存到 sp + 0x8处,就是储存原来的 sp值,图中的origin_sp
ldr     x0, [sp, #0x18]        ;将 sp + 0x18 的值给 X0,让 X0 指向栈顶sub     sp, sp, #0xf0          ;分配空间储存 X0 - X29 寄存器
stp     X0, X1, [SP]
stp     X2, X3, [SP,#0x10]
stp     X4, X5, [SP,#0x20]
stp     X6, X7, [SP,#0x30]
stp     X8, X9, [SP,#0x40]
stp     X10, X11, [SP,#0x50]
stp     X12, X13, [SP,#0x60]
stp     X14, X15, [SP,#0x70]
stp     X16, X17, [SP,#0x80]
stp     X18, X19, [SP,#0x90]
stp     X20, X21, [SP,#0xa0]
stp     X22, X23, [SP,#0xb0]
stp     X24, X25, [SP,#0xc0]
stp     X26, X27, [SP,#0xd0]
stp     X28, X29, [SP,#0xe0]

由于没有了LDM/STM指令,我们向栈上存大量寄存器只能一对一对的来。

接下来,我们可以执行之前被覆盖的代码了,这里不演示,后面写一个demo具体来看。

然后就是恢复寄存器:

ldr     x0, [sp, #0x100]     ;将储存PSR的地址赋值给 X0
msr     NZCV, x0             ;恢复状态寄存器的值ldp     X0, X1, [SP]         ;恢复X0,X1 的值
ldp     X2, X3, [SP,#0x10]
ldp     X4, X5, [SP,#0x20]
ldp     X6, X7, [SP,#0x30]
ldp     X8, X9, [SP,#0x40]
ldp     X10, X11, [SP,#0x50]
ldp     X12, X13, [SP,#0x60]
ldp     X14, X15, [SP,#0x70]
ldp     X16, X17, [SP,#0x80]
ldp     X18, X19, [SP,#0x90]
ldp     X20, X21, [SP,#0xa0]
ldp     X22, X23, [SP,#0xb0]
ldp     X24, X25, [SP,#0xc0]
ldp     X26, X27, [SP,#0xd0]
ldp     X28, X29, [SP,#0xe0]
add     sp, sp, #0xf0         ;将 sp 的值指向X30 LR位置ldr     x30, [sp]             ;恢复 X30 寄存器
add     sp, sp, #0x20         ;将 sp 的值还原,origin_sp没用到

恢复寄存后,我们还需要再跳转到原函数继续执行,执行覆盖指令的最后一行:

ldr   x0, ret_addr
br    x0

这样,一个hook方案的框架就设计好了。

例子

我们尝试对libc.so 中的 fopen 函数进行 inlinehook。

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <sys/mman.h>
#include <dlfcn.h>int main()
{void *handle = dlopen("libc.so", RTLD_NOW);void *hook_addr = dlsym(handle, "fopen");printf("hook_addr = %p\n", hook_addr);return 0;
}

使用,dlopen 与 dlsym 找到 fopen 函数的地址:

sailfish:/data/local/tmp # ./inlinehook                                      
hook_addr = 0x724f5433d4

运行编译后的程序,确认可以正确的获取到地址。如果是32位的so,这个地址可能是一个奇数,说明它是thumb模式。

接下来,我们编写hook函数,需要注意的是编译器对于一般的函数都会生成 prolog 和 epilog 代码,由于我们需要完全控制 hook 函数生成的汇编指令,所以需要使用裸函数,或者直接写汇编。

void __attribute__((naked)) hook_func()
{}

我们需要覆盖 fopen 函数的头部指令,需要就需要对 .text 段修改,由于 .text 是没有写权限的,所以需要使用 mprotect 函数来获取写权限。

mprotect((void *)((uint64_t)hook_addr & 0xfffffffffffff000), 0x1000, PROT_WRITE | PROT_EXEC | PROT_READ);

可以查看 /proc/pid/maps 文件,看看是否生效:

7454e64000-7454edc000 r-xp 00000000 103:12 946                           /system/lib64/libc.so
7454edc000-7454edd000 rwxp 00078000 103:12 946                           /system/lib64/libc.so
7454edd000-7454f2c000 r-xp 00079000 103:12 946                           /system/lib64/libc.so

可以看到,确实有一个段的权限变成了 rwx。

现在,可以开始覆盖指令了,但是蛋疼的地方出现了,在 ARM32 中,就非常的简单,只需要使用 LDR 就行:

LDR pc, [pc-4]
hook_func_addr

上面的这两条指令,第一条是将下一条指令内容赋值给PC,所以,第二条指令其实不是一个真实的指令,而是hook函数的地址,这样非常简单的实现了hook函数的跳转。

在ARM64中,我们无法操作pc寄存器,只能使用 B 指令来进行跳转:

LDR X0, [TARGET_ADDRESS]
BR X0
LDR X0, [SP, -0x8]

这里出现一个问题,TARGET_ADDRESS 是一个动态值,我们没办法在编译的时候确定。那该怎么办呢?需要采用一种特殊的写法:

STP X1, X0, [SP, #-0x10]   ;储存 x0,x1
LDR X0, 8                  ;将pc+8的地址处内容给x0
BR X0                      ;跳转到x0对应的地址
ADDR(64)                   ;目标地址
LDR X0, [SP, -0x8]         ;让x0指向栈顶

ADDR 是hook函数地址,将这个地址给X0,然后使用 BR X0 进行跳转。注意这个地址占据了8个字节,所以实际上相当于6条指令。

LDR X0, 8 这个需要特别解释一下,这个 ida 的 patch program 生成的偏移是相对于 so 的基址的,不是相对于 pc,想要生成相对于 pc 的指令,需要按下面的写法:

LDR X0, .+8

我看了一个开源项目,它是直接使用的 LDR X0, 8,不知道是不是汇编语言写法处理方式不一样。

在IDA中,生成这些指令的汇编代码,然后覆盖掉 fopen 的函数起始地址的6条指令:

// 指令是4字节的,使用 uint32,地址使用 unit64// STP X8, X0, [SP, #-0x60]  -> E8 03 3A A9
(*(uint32_t *)(hook_addr + 0)) = 0xA93A03E8;// LDR X0, 8 -> 40 00 00 58
(*(uint32_t *)(hook_addr + 4)) = 0x58000040;// BR X0  -> 00 00 1f d6
(*(uint32_t *)(hook_addr + 8)) = 0xd61f0000;// ADDR  -> 00 00 1f d6,这里要用64位
(*(uint64_t *)(hook_addr + 12)) = hook_func;// 被覆盖的指令操作了sp,所以需要合并计算sp的值(-0x60+0x50)
// LDR X0, [SP, #-0x10]  -> E0 03 5F F8
(*(uint32_t *)(hook_addr + 20)) = 0xF85F03E0;

这里有一些细节需要注意:

  1. 我们在 sp - 0x60 的位置写入了 X8 与 X0,这是因为我们覆盖的 fopen 指令它会操作sp,它的函数栈大小是 0x50,所以我就将X8 与 X0 放到了 fopen 操作不到的位置,避免覆盖的指令修改栈中的数据导致我们的储存的数据丢失。

  2. 存放 X8 是因为后面,我们生成跳转回来的指令时,gcc 使用到了 X8。

  3. 存放 X0 是因为我们跳转使用的是 X0 寄存器,所以需要储存,后面也要还原。

覆盖指令写好之后,我们就可以写 hook 函数了:

void __attribute__((naked)) hook_func()
{// 获取参数asm("ldr x0, [sp, #-0x58]");asm("str x0,%0":"=m"(x0));// 执行被覆盖的指令// .text:00000000000783D4 FF 43 01 D1                   SUB             SP, SP, #0x50           ; Alternative name is 'fopen'// .text:00000000000783D8 F7 0B 00 F9                   STR             X23, [SP,#0x10]// .text:00000000000783DC F6 57 02 A9                   STP             X22, X21, [SP,#0x20]// .text:00000000000783E0 F4 4F 03 A9                   STP             X20, X19, [SP,#0x30]// .text:00000000000783E4 FD 7B 04 A9                   STP             X29, X30, [SP,#0x40]// .text:00000000000783E8 FD 03 01 91                   ADD             X29, SP, #0x40asm("SUB             SP, SP, #0x50");asm("STR             X23, [SP,#0x10]");asm("STP             X22, X21, [SP,#0x20]");asm("STP             X20, X19, [SP,#0x30]");asm("STP             X29, X30, [SP,#0x40]");asm("ADD             X29, SP, #0x40");// 跳转到返回地址,这个语句生成的汇编,用到了 x8 寄存器asm("ldr x0, %0" ::"m"(hook_return_addr));// 还原 x8 寄存器的值asm("ldr x8, [sp, #-0x8]");asm("br x0");
}

hook 函数我写的比较简单,主要做了3件事:

  • 获取 X0 的值,因为X0是第一个参数,我们可以将 X0 赋值给一个全局变量,然后打印出来看是否hook成功

  • 执行被覆盖的指令,这个很简单,将 fopen 的前6条指令 copy 过来就行了

  • 执行完之后,要跳转回 fopen 继续执行,我们是先计算出了指令的地址,然后使用行内汇编来生成对应的指令

除了使用行内汇编,还可以直接使用汇编来实现,这个我也不太熟,简单介绍一下。

首先在汇编文件里面定义一个变量:

.global _shellcode_start_s

然后,将这个变量当成标号使用:

_shellcode_start_s:sub     sp, sp, #0x20

在别的C文件里面,使用 extern 就可以直接引用这变量了:

extern unsigned long _shellcode_start_s;oid *p_shellcode_start_s = &_shellcode_start_s;

这样,我们就拿到了hook函数的起始地址。有兴趣的可以看下 GitHub 的相关开源项目。

源码

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <sys/mman.h>
#include <dlfcn.h>static uint64_t hook_return_addr;
static uint64_t x0, x1;void __attribute__((naked)) hook_func()
{// 获取参数asm("ldr x0, [sp, #-0x58]");asm("str x0,%0" : "=m"(x0));asm("str x1,%0" : "=m"(x1));// 执行被覆盖的指令// .text:00000000000783D4 FF 43 01 D1                   SUB             SP, SP, #0x50           ; Alternative name is 'fopen'// .text:00000000000783D8 F7 0B 00 F9                   STR             X23, [SP,#0x10]// .text:00000000000783DC F6 57 02 A9                   STP             X22, X21, [SP,#0x20]// .text:00000000000783E0 F4 4F 03 A9                   STP             X20, X19, [SP,#0x30]// .text:00000000000783E4 FD 7B 04 A9                   STP             X29, X30, [SP,#0x40]// .text:00000000000783E8 FD 03 01 91                   ADD             X29, SP, #0x40// .text:00000000000783EC 56 D0 3B D5                   MRS             X22, #3, c13, c0, #2// .text:00000000000783F0 C9 16 40 F9                   LDR             X9, [X22,#0x28]asm("SUB             SP, SP, #0x50");asm("STR             X23, [SP,#0x10]");asm("STP             X22, X21, [SP,#0x20]");asm("STP             X20, X19, [SP,#0x30]");asm("STP             X29, X30, [SP,#0x40]");asm("ADD             X29, SP, #0x40");// 跳转到返回地址,这个语句生成的汇编,用到了 x8 寄存器asm("ldr x0, %0" ::"m"(hook_return_addr));// 还原 x8 寄存器的值asm("ldr x8, [sp, #-0x8]");asm("br x0");
}int main()
{void *handle = dlopen("libc.so", RTLD_NOW);void *hook_addr = dlsym(handle, "fopen");if (hook_addr != NULL){hook_return_addr = hook_addr + 20;}else{return -1;}printf("hook_addr = %p\n", hook_addr);// 这里改变了 0x1000 这个范围的数据的属性mprotect((void *)((uint64_t)hook_addr & 0xfffffffffffff000), 0x1000, PROT_WRITE | PROT_EXEC | PROT_READ);// getchar();// 指令是4字节的,使用 uint32,地址使用 unit64// STP X8, X0, [SP, #-0x60]  -> E8 03 3A A9(*(uint32_t *)(hook_addr + 0)) = 0xA93A03E8;// LDR X0, 8 -> 40 00 00 58(*(uint32_t *)(hook_addr + 4)) = 0x58000040;// BR X0  -> 00 00 1f d6(*(uint32_t *)(hook_addr + 8)) = 0xd61f0000;// ADDR  -> 00 00 1f d6,这里要用64位(*(uint64_t *)(hook_addr + 12)) = hook_func;// 被覆盖的指令操作了sp,所以需要还原// LDR X0, [SP, #-0x10]  -> E0 03 5F F8(*(uint32_t *)(hook_addr + 20)) = 0xF85F03E0;printf("hook_func = %p\n", hook_func);getchar();FILE *fp = fopen("/data/local/tmp/android_server64", "rb");uint32_t data;fread(&data, 4, 1, fp);fclose(fp);printf("data = %p\n", data);printf("x0 = %s\n", x0);printf("x1 = %s\n", x1);return 0;
}

运行程序,输出:

sailfish:/data/local/tmp # ./inlinehook                         
hook_addr = 0x7c1015a3d4
hook_func = 0x5d3d3099d4data = 0x464c457f
x0 = /data/local/tmp/android_server64
x1 = rb

可以看到我们成功的hook了 fopen 函数:

  • data 是 so 文件的前4个字节,就是 .elf

  • x0 与 x1 是参数

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/pingmian/30719.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

WordPress主题 酱茄免费主题

酱茄free主题由酱茄开发的一款免费开源的WordPress主题&#xff0c;主题专为WordPress博客、资讯、自媒体网站而设计&#xff0c;遵循GPL V2.0开源协议发布。 运行环境 酱茄Free主题当前版本&#xff1a;2020.11.25 V1.0.0 支持WordPress版本&#xff1a;5.4 兼容Chrome、Fire…

word复制技巧二则

1 纵向复制 按下Alt键&#xff0c;按下鼠标左键拖动&#xff0c;选中要纵向复制的内容&#xff0c;如下图&#xff0c; 再粘贴即可&#xff1b; 2 整页复制 在页的任意位置单击&#xff0c;然后按CtrlA&#xff0c;这会选中整页&#xff1b;然后再复制粘贴即可&#xff1b;

更换域名流程记录

华为云的服务器&#xff0c;阿里云购买的域名。 1.购买域名 2.在域名服务商绑定服务器ip&#xff08;以阿里云为例&#xff09; 控制台->域名控制台->域名列表->点击域名->域名解析->添加记录 记录类型填A , 主机记录“”或“www”&#xff0c;记录值填服务器i…

最新暑假带刷规划:50天吃透660+880!

现在只刷一本题集根本不够 去做做24年的考研真题卷就什么都明白了&#xff0c;24年的卷子就是典型的知识点多&#xff0c;杂&#xff0c;计算量大。 而现在市面上的任何一本题集&#xff0c;都无法做到包含所有的知识点&#xff0c;毕竟版面有限&#xff01; 所以&#xff0…

iptables(3)规则管理

简介 上一篇文章中,我们已经介绍了怎样使用iptables命令查看规则,那么这篇文章我们就来介绍一下,怎样管理规则,即对iptables进行”增、删、改”操作。 注意:在进行iptables实验时,请务必在个人的测试机上进行,不要再有任何业务的机器上进行测试。 在进行测试前,为保障…

【JavaEE精炼宝库】多线程(7)定时器

目录 一、定时器的概念 二、标准库中的定时器 三、自己实现一个定时器 3.1 MyTimerTask 实现&#xff1a; 3.2 MyTimer 实现&#xff1a; 一、定时器的概念 定时器也是软件开发中的⼀个重要组件。类似于一个 "闹钟"。达到一个设定的时间之后&#xff0c;就执行…

聊一聊 Monitor.Wait 和 Pluse 的底层玩法

一&#xff1a;背景 1. 讲故事 在dump分析的过程中经常会看到很多线程卡在Monitor.Wait方法上&#xff0c;曾经也有不少人问我为什么用 !syncblk 看不到 Monitor.Wait 上的锁信息&#xff0c;刚好昨天有时间我就来研究一下。 二&#xff1a;Monitor.Wait 底层怎么玩的 1. 案…

Chromium 开发指南2024 Mac篇-Chromium项目编译小技巧(六)

1. 前言 在编译大型项目如 Chromium 时&#xff0c;优化编译速度是非常重要的。本文将介绍一些编译优化的小技巧&#xff0c;尤其是如何使用 ccache 来加速 C/C 代码的重新编译。ccache 是一个编译器缓存&#xff0c;通过缓存之前的编译并检测何时再次进行相同的编译&#xff…

“display interface“的43条信息,这条绝对被你忽略了

号主&#xff1a;老杨丨11年资深网络工程师&#xff0c;更多网工提升干货&#xff0c;请关注公众号&#xff1a;网络工程师俱乐部 大家好&#xff0c;我是张总。 上周和老杨唠嗑&#xff0c;他说我每次都是直播&#xff0c;或者视频号上给大家聊聊技术&#xff0c;都没发过技术…

U盘删除的文件怎么找回?数据恢复,5个方法

“我的u盘里有部分文件不小心被删除了&#xff0c;尝试了很多的方法都无法找回它们。U盘删除的文件怎么找回呢&#xff1f;希望大家给我出出主意&#xff01;” 保存了很多重要的文件在u盘中&#xff0c;查看u盘内存时却发现很多文件被删除了&#xff1f;别慌&#xff01;即使u…

B树(数据结构篇)

数据结构之B树 B-树(B-tree) 概念&#xff1a; B-树是一个非二叉树的多路平衡查找树(数据有序)&#xff0c;是一颗所有数据都存储在树叶节点上的树&#xff0c;不一定存储具体的数据&#xff0c;也可以是指向包含数据的记录的指针或地址 对于**阶为M(子节点数量在2和M之间)*…

Properties与xml知识点总结

文章目录 一、Properties1.1 构造方法1.2 从Properties文件中获取1.3 向Properties文件中存储 二、xml2.1 XML2.2 特点2.3 规则2.3 抬头声明2.4 特殊字符2.5 CDATA区段2.4 作用和应用场景 三、区别 一、Properties 定义&#xff1a;properties是一个双列集合集合&#xff0c;拥…

android 在线程中更新界面

在Android中&#xff0c;你不能直接从子线程中更新UI&#xff0c;因为这会导致应用崩溃。你需要使用Handler或runOnUiThread()来更新UI。 使用Handler 以下是如何使用Handler在子线程中更新UI的示例&#xff1a; 1. 创建Handler实例&#xff1a; import android.os.Bundle;…

产品经理方法论

1、用户体验 5 要素 1&#xff0c;表现层是你拿到一个产品以后&#xff0c;视觉表现&#xff0c;配色&#xff0c;布局&#xff0c;排版等等 2&#xff0c;框架层&#xff0c;是交互层面的东西&#xff0c;比如&#xff0c;操作情况&#xff0c;刷新&#xff0c;页面跳转&…

ChatmoneyAI如狂风般席卷广告创意舞台,轻松闯荡财富之海!

本文由 ChatMoney团队出品 引言 在广告创意行业&#xff0c;创新和高效是赢得市场的关键。而我今天要分享的就是如何利用ChatmoneyAI这款强大的人工智能工具&#xff0c;打破创新难题&#xff0c;赚取丰厚收益。 让我告诉你一个小秘密&#xff0c;有客户曾在一个月内&#xf…

git merge(3个模式) 与 git rebase 图文详解区别

目录 1 git merge1.1 模式一&#xff1a;fast-forward(–ff)1.2 模式二&#xff1a;non-Fast-forward(–no-ff)1.3 模式三&#xff1a;fast-forward only(–ff-only) 2 git rebase3 区别 1 git merge git merge有好几种不同的模式 默认情况下你直接使用 git merge 命令&#x…

从boost库到时间戳

一、以问题引入 授权证书一般有到期时间的说法&#xff0c;公司测试同事在测试更新后的证书时&#xff0c;将系统时间调到了2050年&#xff0c;重启服务后发现各个进程的cpu占用率特别高&#xff1b;结合日志分析&#xff0c;发现这些进程 都在不停的刷heartbeat()的日志&#…

C++17并行算法与HIPSTDPAR

C17 parallel algorithms and HIPSTDPAR — ROCm Blogs (amd.com) C17标准在原有的C标准库中引入了并行算法的概念。像std::transform这样的并行版本算法保持了与常规串行版本相同的签名&#xff0c;只是增加了一个额外的参数来指定使用的执行策略。这种灵活性使得已经使用C标准…

AI 音乐大模型:创新的曙光还是创意产业的阴影?

&#x1f49d;&#x1f49d;&#x1f49d;欢迎来到我的博客&#xff0c;很高兴能够在这里和您见面&#xff01;希望您在这里可以感受到一份轻松愉快的氛围&#xff0c;不仅可以获得有趣的内容和知识&#xff0c;也可以畅所欲言、分享您的想法和见解。 推荐:kwan 的首页,持续学…

[面试题]Kafka

[面试题]Java【基础】[面试题]Java【虚拟机】[面试题]Java【并发】[面试题]Java【集合】[面试题]MySQL[面试题]Maven[面试题]Spring Boot[面试题]Spring Cloud[面试题]Spring MVC[面试题]Spring[面试题]MyBatis[面试题]Nginx[面试题]缓存[面试题]Redis[面试题]消息队列[面试题]…