🔥 Hi,我是小余。 本文已收录到 GitHub · Androider-Planet 中。这里有 Android 进阶成长知识体系,关注公众号 [小余的自习室] ,在成功的路上不迷路!
前言
首先来说下什么是hack? hack字面意思“非法入侵”,那么在C/C++中其实就是使用反汇编查看C/C++代码对应的汇编代码。
那可能有人要问了,C/C++不是高级语言么,为什么还要看汇编代码?理由嘛见仁见智,
小余个人理解有下面几种:
- 1.C/C/C++应用不像java,python那样,报错信息可以在日志中一目了然,C/C++应用的报错可以让你查的怀疑人生,为什么呢?因为报错日志提供的信息可能会误导你,比如真实错误是在100行,但是报错信息会在第一行或者全局都有报错,就很让人上头。。有了这个hack过程,就可以定位到可能报错的地方,使用hack反编译得到汇编代码,汇编代码可以让你看清你写的C/C/C++代码对于CPU来说实际执行的行为。
- 2.当我们在调试C/C++应用的时候,经常会有些疑惑,比如i++和++i为什么结果不同,又比如inline内联函数有什么用处?原理是什么?等等,这些也可以使用hack来解释,那有人要说了,这些我都知道啥意思啊,i++和++i前自增和后自增么,内联函数原理就是编译期将函数代码拷贝到调用处嘛。可是这些都是别人告诉你的,那如果一个代码段别人没告诉你呢?百度是不是什么都可以找到的,特别是对于C/C++开发来说,信息更是少的可怜,此时你就会知道hack是多重要了。
- 3.如果你的C/C++应用需要实现一些性能上优化,也可以使用hack过程查看CPU和内存的行为,从而对应用性能做一些提升,是不是很cool。。
既然有这么多用处,那么下面小余就来讲解下如何在我们的应用中hack。
不过在hack前我们需要了解一些汇编语言基础。
汇编语言基础
首先我们得了解汇编语言中的几个模块:1.寄存器 2.内存 3.CPU
1.寄存器
寄存器是我们计算机中的最小存储单位,在计算机金字塔的顶端,属于CPU的内部存储,主要用来存储CPU需要使用到的临时数据。
CPU访问寄存器的速度比访问内存的速度快了100倍左右。
CPU中寄存器种类包括:通用寄存器,指令地址寄存器,状态寄存器等等等等。。 这里只介绍几个比较关键的寄存器:
1.通用寄存器
寄存器 | 原文 | 解释 | 说明 |
---|---|---|---|
AX | accumulator | 累加寄存器 | 通常用来执行加法,函数调用的返回值一般也放在这里面 |
CX | counter | 计数寄存器 | 通常用来作为计数器,比如for循环 |
DX | data | 数据寄存器 | 数据存取 |
BX | base | 基址寄存器 | 读写I/O端口时,edx用来存放端口号 |
SP | stack pointer | 栈指针寄存器 | 栈顶指针,指向栈的顶部 |
BP | base pointer | 基址指针寄存器 | 栈底指针,指向栈的底部,通常用ebp+偏移量的形式来定位函数存放在栈中的局部变量 |
SI | source index | 源变址寄存器 | 字符串操作时,用于存放数据源的地址 |
DI | destination index | 目标变址寄存器 | 字符串操作时,用于存放目的地址的,和esi两个经常搭配一起使用,执行字符串的复制等操作 |
这里说明几点:
- 1.在具体hack的过程中你可能看到的是eax,ecx,edx等而不是AX,CX,DX,他们区别: 带e开头的说明是32位寄存器,也就是4个字节存储的寄存器,而AX是eax的低16位的“子寄存器”, 这是早期一些寄存器是16位的,而为了兼容早期这些寄存器就使用“子寄存器”来标识。
- 2.汇编语言中,在函数体返回时,统一使用eax为返回值,这个为什么这样?我也不清楚,可能是早期大家都这么用就约定下来了吧。
- 3.当我们在使用函数的时候,会将ebp的内容压入栈底,作为栈底指针,指向栈的底部,局部变量使用ebp+偏移地址来定位。对于栈顶则使用esp寄存器来定位,关于函数栈会在后面讲解。
2.eip寄存器
eip寄存器可以说是CPU中最关键的一个寄存器了,它指向了下一条要执行的指令所存放的地址, CPU工作其实就是取出eip中的地址中的指令,然后去执行这条指令,并将下一条指令的地址赋值给eip,这样CPU就可以依次执行完成所有的汇编代码.
如果你要问我它是怎么找到下一条指令的地址的,首先正常执行指令是+1位下一条指令地址,如果碰到一些比如if语句,函数调用等情况,就会在上一条指令中得到这个地址,并赋值给eip,这个赋值动作是CPU自动完成的,开发是不能随便更改的,这也变相的防止你搞出bug不是?
3.状态寄存器
状态寄存器中记录了CPU执行过程中的一系列状态,对于32位就有32个标志位,这些标志大部分都是由CPU自行设置:
寄存器标志位 | 解释 |
---|---|
CF | 进位标志 |
PF | 奇偶标志 |
ZF | 零标志 |
SF | 符号标志 |
OF | 补码溢出标志 |
TF | 跟踪标志 |
IF | 中断标志 |
…… |
4.指令寄存器:
根据指令在存贮器中的地址(由指令地址计数器给出),把指令从存贮器中取出来之后,需要有一个专门用于存放指令的地方,以便对指令进行分析和执行。这个专门存放现行指令的部件就叫做指令寄存器。
后面会具体汇编代码会涉及到。
关于寄存器就讲这么几个吧,hack过程够用了。
2.内存
这里讲解的内存一般是指高速缓存(Cache)或者主存。
计算机在运行程序时,首先将程序从磁盘读取到主存,然后CPU按规则从主存中取出指令,数据并执行指令,但是直接从主存(一般是DRAM)中读写是很慢的,所以引入了高速缓存(Cache)(使用的是SRAM)。
在程序运行前首先会试图将指令,数据从主存中读取到Cache中,然后在程序执行时直接访问Cache, 如果指令和数据可以从Cache中读取到,那么就说是“命中(hit)”,反之就是“不命中(miss)”, miss情况下需要从主存中读取指令或者数据,这样会直接影响CPU的性能,所以命中率对CPU来说至关重要。
内存在计算中又会分为:堆区,栈区,全局(静态)区,常量区,代码区等等。
这里讲解下hack中比较关键的栈区。
栈区
-
栈中存放了局部变量,参数,函数的返回地址等
-
栈区由编译器自动分配释放,由操作系统自动管理,无须手动管理
-
栈区中的局部变量只在该函数作用域中有效,函数退出后就会被销毁、
栈区就像一个桶一样,桶底就是栈底为高地址区,桶的顶部为低地址, 桶中放东西当然是先放桶底然后依次叠加咯,也就是平时所说的FILO先进后出的模式。
3.CPU
1.CPU的功能
指令控制(程序的顺序控制)
操作控制(一条指令由若干操作信号实现)
时间控制(指令各个操作实施时间的定时)
数据加工(算术运算和逻辑运算)
2.CPU的基本组成
中央处理器 CPU= 运算器 + 控制器
运算器:
ALU
累加器
暂存器
控制器
程序计数器 (PC) 、指令寄存器 (IR) 、数据缓冲器 (DR) 、地址寄存器 (AR) 、通用寄存器、状态寄存器 (PSW) 、时序发生器、指令译码器 (ID) 、总线
CPU的工作分为以下 5 个阶段:取指令阶段、指令译码阶段、执行指令阶段、访存取数和结果写回。
- 取指令(IF,instruction fetch),即将一条指令从主存储器中取到指令寄存器的过程。程序计数器中的数值,用来指示当前指令在主存中的位置。当 一条指令被取出后,程序计数器(PC)中的数值将根据指令字长度自动递增。
- 指令译码阶段(ID,instruction decode),取出指令后,指令译码器按照预定的指令格式,对取回的指令进行拆分和解释,识别区分出不同的指令类 别以及各种获取操作数的方法。现代CISC处理器会将拆分已提高并行率和效率
- 执行指令阶段(EX,execute),具体实现指令的功能。CPU的不同部分被连接起来,以执行所需的操作。
- 访存取数阶段(MEM,memory),根据指令需要访问主存、读取操作数,CPU得到操作数在主存中的地址,并从主存中读取该操作数用于运算。部分指令不需要访问主存,则可以跳过该阶段。
- 结果写回阶段(WB,write back),作为最后一个阶段,结果写回阶段把执行指令阶段的运行结果数据“写回”到某种存储形式。结果数据一般会被写到CPU的内部寄存器中,以便被后续的指令快速地存取;许多指令还会改变程序状态字寄存器中标志位的状态,这些标志位标识着不同的操作结果,可被用来影响程序的动作。 在指令执行完毕、结果数据写回之后,若无意外事件(如结果溢出等)发生,计算机就从程序计数器中取得下一条指令地址,开始新一轮的循环,下一个指令周期将顺序取出下一条指令。 [1] 许多复杂的CPU可以一次提取多个指令、解码,并且同时执行。
一些基础的汇编命令:
1.数据传送指令
-
1.mov :传送指令,也可以单纯理解为赋值语句,这个语句在汇编中是最常用的。 格式:mov det,src
首先mov语句分以下几种情况:
- 1.CPU内部寄存器之间数据传送:mov ebp,esp ,将esp寄存器中的数据传递给ebp寄存器。
- 2.立即数传递到通用寄存器:mov ebp 3 ,将立即数3传递到ebp寄存器中。
- 3.立即数传递到内存存储单元:mov [bx] 123h,将立即数传递到bx指向的内存地址中。
- 4.内存和CPU之间数据传送:内存传递到CPU寄存器中:mov eax,dword ptr [a] ,CPU寄存器传递到内存中:mov dword ptr [a],eax。
2.加减运算指令
首先要了解,加减法运算是会改变状态寄存器中的值的,如一个有符号数的运算高位溢出标志(OF),无符号的运算进位标志(CF)等。
加法指令:add、adc、inc
-
1.add
格式:add OPRD1,OPRD2 功能:OPRD1 = OPRD1 + OPRD2 举例: add eax ecx;//得到的结果为eax = eax+ecx;
-
2.adc
格式:add OPRD1,OPRD2 功能:OPRD1 = OPRD1 + OPRD2 举例: adc eax ecx;得到的结果为eax = eax+ecx+状态寄存器中给的进位标志CF;
-
3.inc
格式:add OPRD 功能:OPRD = OPRD + 1 举例: inc eax;//得到的结果为eax = eax+1;
减法指令:sub,dec,cmp,当然还有和adc对应的sbb 这里讲解下sub和dec
-
1.sub
格式:sub OPRD1,OPRD2 功能:OPRD1 = OPRD1 - OPRD2 举例: sub eax ecx;//得到的结果为eax = eax - ecx;
-
2.dec
格式:dec OPRD 功能:OPRD = OPRD - 1 举例: dec eax;//得到的结果为eax = eax - 1;
-
3.cmp
格式:cmp OPRD1,OPRD2 功能:执行OPRD1 - OPRD2,但运算结果不运送到OPRD1 注:该指令通过OPRD - OPRD2影响标志位CF、ZF、SF、OF、AF、PF来判断OPRD1和OPRD2的大小关系。 通过ZF判断是否相等;如果是无符号数,通过CF可判断大小;如果是有符号数,通过SF和OF判断大小
3.逻辑运算和移位指令
1、逻辑运算指令:not、and、or、xor、test
-
(1) NOT:非运算
格式:NOT OPRD 功能:把操作数OPRD取反,然后送回OPRD。 注: OPRD可以是通用寄存器,也可以是存储器操作数,此指令对标志没有影响
-
(2) AND:与运算
格式:AND OPRD1,OPRD2 功能:对两个操作数进行按位逻辑“与”运算,结果送到OPRD1中 注: 该指令执行后,CF=0,OF=0,标志PF、ZF、SF反映运算结果,AF未定义。 某个操作数与自身相与,值不变,但可以使CF置0。
-
(3) OR:或运算
格式:OR OPRD1,OPRD2 功能:对两个操作数进行按位逻辑“或”运算,结果送到OPRD1中 注: 该指令执行后,CF=0,OF=0,标志PF、ZF、SF反映运算结果,AF未定义。 某个操作数与自身相或,值不变,但可以使CF置0。
-
(4) XOR:异或运算
格式:XOR OPRD1,OPRD2 功能:对两个操作数进行按位逻辑“异或”运算,结果送到OPRD1中
2、一般移位指令:SAL/SHL,SAR/SHR
-
(1) SAL/SHL(Shift Arithmetic Left / Shift Logic Left);算术左移/逻辑左移
格式:SAL OPRD,m SHL OPRD,m 功能:把操作数OPRD左移m位,每移动一位,右边用0补足1位,移出的最高位进入标志位CF 注: 算术左移和逻辑左移进行相同的动作,为了方便提供了两个助记符。
-
(2) SAR(Shift Arithmetic Right) ;算数右移指令
格式:SAR OPRD,m 功能:操作数右移m位,同时每移1位,左边的符号位保持不变,移出的最低位进入标志位CF 注: 对有符号数和无符号数,算数右移1位相当于除以2 如:SAR BH,1 ;(BH)= 80H,指令执行后(BH)= C0H
-
(3) SHR(Shift Logic Right) ;逻辑右移指令
格式:SHR OPRD,m 功能:操作数右移m位,同时每移1位,左边用0补足,移出的最低位进入标志位CF 注: 对无符号数,逻辑右移1位相当于除以2 如:SHR AH,1 ;(AH)= 80H,指令执行后(AH)= 40H
切记:算术移位和逻辑移位的区别:
对于左移不管是算术移位和逻辑移位,低位都补0; 对于右移的算术移位:对于有符号数,符号位不变,然后整体右移。
4.转移指令
1、无条件转移指令:JMP(Jump)
直接跳转到Jump指定的地址,跳转指令也是改变当前eip的值来决定的,这样下次取指令会去新的eip地址下取。
格式:JMP OPRD
功能:使控制指令无条件转移到OPRD的内容给定的目标地址处。操作数OPRD可以是通用寄存器,也可以是字存储单元
例如:
jmp cx ;CX寄存器的内容送IP
jmp word ptr [1234h] ;字存储单元[1234h]的内容送IP
2、条件转移指令
指令的汇编格式及功能根据条件码的值转移:
ja 大于时跳转
jae 大于等于
jb 小于
jbe 小于等于
je 相等
jna 不大于
jnae 不大于或者等于
jnb 不小于
jnbe 不小于或等于
jne 不等于
jg 大于(有符号)
jge 大于等于(有符号)
jl 小于(有符号)
jle 小于等于(有符号)
jng 不大于(有符号)
jnge 不大于等于(有符号)
jnl 不小于
jnle 不小于等于
jns 无符号
jnz 非零
js 如果带符号
jz 如果为零
例子:
mov eax, 1
cmp eax, 100
jle xiao_deng_yu_100
sub eax, 20
xiao_deng_yu_100:
add eax, 1
ret
这个例子中就是让eax中存储的值和100比较,如果小于等于则跳转到xiao_deng_yu_100处。 从这个例子也可以看出:
- 1.条件转移一般会和cmp比较指令配合使用,因为比较指令会改变状态寄存器中的标志位,而jle等跳转指令回去状态寄存器中读取这些标志位。
- 2.跳转指令不会返回到原来的指令地址处,后面讲解函数跳转的时候可以看到会返回到原来的指令地址处,根据返回地址。
条件跳转指令这么多,怎么记住呢?这里面是有套路的: 首先,跳转指令的前面都是字母j,关键是j后面的的字母,比如j后面是ne,对应的是jne跳转指令,n和e分别对应not和equal,也就是“不相等”,也就是说在比较指令的结果为“不想等”的时候,就会跳转。
a: above
e: equal
b: below
n: not
g: greater
l: lower
s: signed
z: zero
好了,这里列出来了j后面的字母所对应的含义。根据这些字母的组合,和上述大概的规则,你就能清楚怎么写出这些跳转指令了。当然,这里有“有符号”和“无符号”之分,后面有机会再扯,读者也可以自行了解。
5.堆栈操作指令
我们知道,在一个函数作用域中会使用到一些寄存器,如果在这个函数中又调用了另外一个函数,那这些寄存器信息可能会被覆盖掉,怎么办呢? 首先CPU会在内存中开辟一块空间,叫栈空间,CPU将这些寄存器压入到栈中,叫入栈,待另外一个函数返回时,再将当前栈中的信息恢复回来,叫出栈。
1.入栈指令push
格式:push src
功能: 把数据src压入到栈中
注意:src可以是寄存器或者内存中的数据。
2.出栈指令pop
格式:pop dst
功能: 把数据弹出到到栈中
注意:dst可以是通用寄存器和段寄存器,但不能是CS,可以是字存储单元。
6.函数调用跳转指令
格式:call 标号
功能:跳转到另外一个函数去执行。
举例:
eax_plus_1s:
add eax, 1
ret
main:
mov eax, 0
call eax_plus_1s
ret
这里的eax_plus_1s就是一个函数,使用call跳转
call方法执行之前CPU还会做一个动作,就是将当前eip保存起来,然后再去跳转,这是为了函数执行完成后可以找到回来执行的地址。
好了。有了以上基础后,我们再来看C/C++中的hack环节。
hack
那我们就拿前面的举得例子来讲解下hack过程
1.前增++i和后增i++
我们打开vs中输入下面一段代码:
int main()
{
int a = 0;
int b = ++a;
return 0;
}
如何反汇编呢,在main的两个括号{}处打两个断点,然后执行程序,右击选择反汇编。 得到的反汇编代码如下:
int main()
//段1:初始化
{
001947C0 push ebp
001947C1 mov ebp,esp
001947C3 sub esp,0D8h 001947C9 push ebx 001947CA push esi 001947CB push edi 001947CC lea edi,[ebp-18h] 001947CF mov ecx,6 001947D4 mov eax,0CCCCCCCCh 001947D9 rep stos dword ptr es:[edi] 001947DB mov ecx,offset _BCEF6C65_20221229-demo-cAndCpp@cpp (01A8052h) 001947E0 call @__CheckForDebuggerJustMyCode@4 (0173F1Eh) //段2: int a = 0;
001947E5 mov dword ptr [a],0
int b = ++a;
001947EC mov eax,dword ptr [a]
001947EF add eax,1
001947F2 mov dword ptr [a],eax
001947F5 mov ecx,dword ptr [a]
001947F8 mov dword ptr [b],ecx
return 0;
001947FB xor eax,eax
//段3:反初始化
}
001947FD pop edi
001947FE pop esi
001947FF pop ebx
00194800 add esp,0D8h
00194806 cmp ebp,esp
00194808 call __RTC_CheckEsp (0173AC8h)
0019480D mov esp,ebp
0019480F pop ebp
00194810 ret
这段汇编代码怎么读呢?由于main方法中没有其他函数调用,所以CPU会按顺序执行这段代码。 我们将代码分为3段: 段1:首先来看前面这段代码:
001947C0 push ebp
001947C1 mov ebp,esp
001947C3 sub esp,0D8h
001947C9 push ebx
001947CA push esi
001947CB push edi
001947CC lea edi,[ebp-18h]
001947CF mov ecx,6
001947D4 mov eax,0CCCCCCCCh
001947D9 rep stos dword ptr es:[edi]
001947DB mov ecx,offset _BCEF6C65_20221229-demo-cAndCpp@cpp (01A8052h)
001947E0 call @__CheckForDebuggerJustMyCode@4 (0173F1Eh)
这段代码不用管太多,只是对函数栈的一个初始化的操作,ebp指向栈底,esp指向栈底,还有一些就是当前栈的基地址,返回地址等等信息。因为函数中可能还会调用函数,所以每个函数调用都会有自己的栈,也叫函数的栈帧,函数的深度就代表栈深,但是CPU中的寄存器又是共享的,函数a在使用这些寄存器的时候,函数b又要使用了,那怎么办?方法就是将寄存器中的数值压入栈帧中保存起来,等函数b结束后,再从栈帧中恢复起来就好了。
下面看重点代码:
段2:
int a = 0;
001947E5 mov dword ptr [a],0 //1
int b = ++a;
001947EC mov eax,dword ptr [a] //2
001947EF add eax,1 //3
001947F2 mov dword ptr [a],eax //4
001947F5 mov ecx,dword ptr [a] //5
001947F8 mov dword ptr [b],ecx //6
return 0;
001947FB xor eax,eax //7
分析:
- 1.mov指令将0赋值到内存中的一个a变量地址中。
- 2.mov指令将变量a地址中的值赋值给eax,此时eax变为0.
- 3.add指令给eax+1,此时eax变为了1.
- 4.mov指令将eax的值传递到变量a中,此时a变为了1.
- 5.mov指令将a中的值传递给寄存器ecx,此时ecx值变为1.
- 6.mov指令将ecx的值传递给变量b地址,这样b就变为了1。
以上过程需要注意点:
a的值变化是在b的值变化之前,所以++a是先a+1,然后再将a+1赋值给b。
下面我们再来看下这段代码:
int main()
{
int a = 0;
int b = a++;
return 0;
}
反汇编后:同样找到关键代码:
int a = 0;
00AB47E5 mov dword ptr [a],0 //1
int b = a++;
00AB47EC mov eax,dword ptr [a] //2
00AB47EF mov dword ptr [b],eax //3
00AB47F2 mov ecx,dword ptr [a] //4
00AB47F5 add ecx,1 //5
00AB47F8 mov dword ptr [a],ecx //6
return 0;
00AB47FB xor eax,eax
}
分析:
- 1.给a赋值为0
- 2.将a传递给eax,此时eax变为了0
- 3.将eax传递给b,此时b变为了0
- 4.将a值传递给ecx,此时ecx变为了0
- 5.给ecx+1,此时ecx变为了1
- 6.将ecx值传递给a,此时a变为了1
这个过程可以看到CPU是先赋值给b,然后再让a进行+1的操作。
通过对a++和++a的分析,也可以看到hack可以让我们了解底层是如何执行我们写的代码的。
下面我们再来看一个函数调用过程:
2.函数体hack过程
int maxab(int a,int b) {
return a > b ? a : b;
}
int main()
{
maxab(3, 4);
return 0;
}
反汇编后:
maxab(3, 4);
00A347E5 push 4 //1
00A347E7 push 3 //2
00A347E9 call maxab (0A13794h) //3
00A347EE add esp,8 //4
int maxab(int a,int b) {
return a > b ? a : b;
00A32685 mov eax,dword ptr [a] //5
00A32688 cmp eax,dword ptr [b] //6
00A3268B jle __$EncStackInitStart+2Ch (0A32698h) //7
00A3268D mov ecx,dword ptr [a] //8
00A32690 mov dword ptr [ebp-0C4h],ecx //9
00A32696 jmp __$EncStackInitStart+35h (0A326A1h) //10
00A32698 mov edx,dword ptr [b] //11
00A3269B mov dword ptr [ebp-0C4h],edx //12
00A326A1 mov eax,dword ptr [ebp-0C4h] //13
}
分析:
- 1和2处将立即数4和3压入到栈内。注意他是先push的右边参数4,再push的左边参数3。
- 3处调用call跳转到maxab函数处,注意5处以后这个时候是处于另外一个函数栈内了。
- 5处将变量a的值3传递给eax,a的值是怎么得到的呢?看前面压栈操作是先压入右边再压入左边,根据FILO规则,此时先找到的参数就是先出左边3,再出右边4.
- 6处让eax也就是前面的a的值和b的值进行比较,比较结果会写入到状态寄存器中。
- 7处的jle是小余等于就跳转,这个比较结果是在状态寄存器中获取到的,a是3,b是4,所以结果就是小余咯。条件成功跳转到指定的0A32698h位置。0A32698h指向11处:00A32698 mov edx,dword ptr [b] //11
- 11处使用mov指令将b中的值传递给edx。
- 然后在12处将edx存入到ebp-0C4h位置,ebp是栈底,根据栈的特点,ebp-C4H其实是一个往栈顶走的一个操作,如果你观察仔细,可以看到其实就是栈顶。
- 最终在13处将ebp-0C4h处的值传递给eax,最终返回eax。 前面也说过所有函数都使用eax进行返回。
上面讲解了一个max函数的hack过程,如果你对hack过程还比较模糊,还可以使用vs提供的内存监视器查看具体内存变化:
好了,关于函数的hack分析过程就这里了,大部分操作其实还是要理解汇编一些基本指令以及CPU和内存,栈的模型等。
总结
本篇文章主要讲解了hack的定义以及hack过程中需要了解的几个基本知识: 如寄存器,内存,CPU以及一些基本汇编指令。并使用两个例子来讲解如何在C/C++中进行hack的过程。
对于一些初学者以上知识可能有点晦涩难懂,可以先找一些比较基础的书本文章看看。 好了,我是小余,欢迎点赞关注,我们下期见。
参考
今天的文章【重学C/C++系列(四)】:函数体hack过程详解分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/17838.html