吾爱破解 - LCG - LSG |安卓破解|病毒分析|www.52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 40235|回复: 62
收起左侧

[调试逆向] Windbg新手入坑指南

    [复制链接]
鬼手56 发表于 2019-4-8 21:57
本帖最后由 鬼手56 于 2019-4-8 21:59 编辑

前言

这篇文章是我学习windbg的一个笔记和总结,通过和OD的功能来对比学习windbg的一些理论和命令,达到能调试一个exe或者sys文件的目的。大神请飘过~

熟悉理论 提高调试效率

在开始调试之前,了解以下理论知识可以帮助你大大提高调试效率

定制自己的Windbg界面

windbg默认打开就只有一个Command窗口,但是这样调试效率很低,所以可以先设置好自己的用户界面,下面是我的Windbg界面,因为用惯了OD,所以这个完全就是仿制的OD的界面,

1554539588077.png

所有窗口都可以状态栏里调出来

设置完成之后,你可以保存到工作空间,这样下次再次打开时这个界面就会保留

1554539784517.png

关于工作空间

工作空间保存有断点 用户定义的别名 调试器的设置 图形界面信息 调试会话状态等等信息,类似VS的项目文件,PS的工作区。

命令概述

WinDBG主要是以命令方式工作的,WinDBG共支持三类命令:标准命令、元命令和扩展命令

标准命令

标准命令通常是一两个字符(version除外)或者符号,用来提供适用于各种调试目标的最基本调试功能。标准命令是不分大小写的。比如:

  • g 运行
  • t 单步步入
  • p 单步步过
  • r 查看和修改寄存器

元命令

元命令用来提供标准命令没有提供的调试功能,与标准命令一样,元命令也是内建在调试器引擎或者WinDBG程序文件中的。
所有元命令都以一个点(.)开始,所以元命令也被称为点命令,例如:

  • .reload 重新载入符号
  • .reboot 重启目标机器
  • .restart 重启调试器
  • .logfile 显示信息

扩展命令

扩展命令用于扩展某一方面的调试功能。与标准命令和元命令是内建在WinDBG程序文件中不同,扩展命令是实现在动态加载的扩展模块(DLL)文件中的。

所有的扩展命令都以!开头

通过WinDBG的SDK,用户可以编写自己的扩展模块和扩展命令,例如漏洞测试常用的一个mona插件

  • !mona

调试技巧

在开始调试之前,有几个要点,记住以下几个点对于调试事半功倍

  • 直接按回车可以执行上一条命令
  • 使用分分号作为分隔符,可以在同一行输入多条命令
  • 按上下方向键可以浏览和选择以前输入过的命令
  • 当命令提示符显示为BUSY时,即使命令编辑框可以输入命令,但是这个命令也不会被马上执行,要等WinDBG恢复到空闲状态才能执行
  • 使用Ctrl+Break 来终止一个长时间未完成的命令。如果使用KD或则CDB,那么用Ctrl+C
  • 选择菜单->Edit_>Write Window Text to File可以把之前敲过的所有命令记录到文件

伪寄存器

WinDBG自动定义了很多伪寄存器。在命令行和命令文件中都可以使用伪寄存器。WinDBG会自动将其替换(展开)为合适的值。例如下面这个@$scopeip就是一个伪寄存器,它代表当前的eip指针

1554541014139.png

下表列出了windbg所定义的部分寄存器(字典型知识,需要时查阅即可)

伪寄存器 含义
$ea 调试目标所执行上一条指令的有效地址
$ea2 调试目标所执行上一条指令的第二个有效地址
$exp 表达式评估器所评估的上一条表达式
$ra 当前函数的返回地址
$eip 指令指针寄存器
$eventip 当前调试事件发生时的指令指针
$previp 上一事件的指令指针
$relip 与当前事件关联的指令指针
$scopeip 当前上下文的指令指针
$exentry 当前进程的入口地址
$retreg 首要的函数返回值寄存器
$retreg64 64位格式的首要函数返回寄存器
$csp 栈顶指针ESP
$p 上一个内存显示命令所打印的第一个值
$proc 当前进程EPROCESS结构的指针
$thread 当前线程ETHREAD结构的指针
$peb 当前进程的进程环境块(PEB)的地址
$teb 当前线程的线程环境块(TEB)地址
$tpid 拥有当前线程的进程ID(PID)
$tid 当前线程的线程ID
$bpx X号断点的地址
$frame 当前栈帧的序号
$dbgtime 当前时间
$callret 使用.call命令调用的上一个函数的返回值
$ptrsize 调试目标所在系统的指针类型宽度
$pagesize 调试目标所在的系统的内存页字节数

开始实战

控制调试目标

控制调试目标是调试器的一个核心任务。其宗旨就是使调试目标始终处于调试器的控制之下,让调试人员可以可以随心所欲的控制程序的执行状态。在OD可以通过图形界面和各种快捷键随心所欲地控制程序,相比windbg就没那么方便了。但是WinDBG提供了强大的机制和丰富的命令来控制调试目标,这些命令要比OD的功能丰富的多。

单步步入和单步步过

  • t 单步步入
  • p 单步步过

命令格式如下:

p|t [r] [=StartAddress] [count] ["Command"]

  • r表示禁止显示寄存器内容
  • 默认情况下,调试器总是让目标从当前位置开始单步执行,但是也可以通过等号(=)来指定一个新的起始地址,让程序从这个地址开始单步
  • count用来指定单步执行的次数
  • Command用来指定每次单步执行后要执行的命令

例如:

1554552826202.png

首先查看一下当前的反汇编,我想从77e40d8e这个位置开始执行单步 单步两次 不显示寄存器 单步执行完成之后显示调用堆栈,就可以执行这么一条命令,执行完成之后如图:

tr =77e40d8e 2 "kb"

1554552930272.png

单步执行到指定地址

WinDBG提供了pa和ta命令用来执行到指定的代码地址,其命令格式为:

pa|ta [r] [=StartAddress] StopAddress

  • pa是Step To Address的缩写,即单步执行到StopAddress参数所代表的地址处
  • pa和ta的区别在于 ta在遇到函数时会进入函数,windbg的执行结果会显示函数内容;而pa则是直接步过函数,windbg的执行结果不显示函数内容

例如:

查看一下当前的反汇编

1554553648314.png

如果想直接单步到77e9f137的位置,就可以输入下面这条命令,执行结果如下

par 77e9f137

1554553702649.png

单步执行到下一个函数调用

与pa和ta命令类似,pc和tc命令用来单步执行到下一个函数调用指令(call)

pc|tc [r] [=StratAddress] [Count]

  • pc或tc命令都是让调试目标从当前地址或者StartAddress指定的地址恢复执行,直到遇到函数调用时停下来
  • Count用来指定遇到的函数调用指令个数
  • 这两个的差别依然是在进入和不进入函数时windbg显示的结果上有区别

例如:

首先来查看一下当前的反汇编

1554554139677.png

如果想直接单步到77e9f137这个位置的call,就可以直接用下面一条命令

pcr

1554554204372.png

单步执行到下一个分支

CPU有分支的监视和记录功能,利用这一功能可以实现单步执行到分支,但是这个命令有一个缺陷就是不能在x86的用户态模式下使用,命令格式如下:

tb [r] [=StartAddress] [Count]

继续运行

g(go)命令的一般形式为:

g[a][=StartAddress] [BreakAddress][;BreakCommands]

  • 其实StartAddress用来指定开始执行的起始地址 这个功能有点像OD的此处为新的EIP
  • BreakAddress用来指定一个断点地址
  • BreakCommands用来指定断点命中后所指定命令
  • 如果不带任何参数,那么g命令就是恢复目标运行 相当于OD的F9
  • 可以用gu命令来执行到返回 相当于OD的Ctrl+F9

追踪并监视

如果我们想了解一个函数的执行路径和它调用了哪些其它函数,每个函数包含了多少条指令,但我们又不想一步步的跟踪执行,那么可以使用wt命令让它帮我跟踪执行并生成一份报告给我

下面通过一个例子来解释wt命令的用法,注意:wt命令必须在函数的起始位置处,也就是步入call之后的第一条指令时执行

首先单步步入函数。然后使用wt命令生成报告

1554555175540.png

可以把wt命令的结果分为六个部分

  • 第一个部分是标题 显示了追踪的函数名和追踪的结束地址
  • 第二部分是详细的执行情况 包括如下四列:
  • 第一列为指令数,这一列的数字就是这个函数从入口进入到下一行所对应的函数入口所执行的指令
  • 第二列用来显示本行所对应的函数调用其他函数时所执行的总指令数
  • 第三列表示函数的调用深度 没进入一个函数深度加一
  • 第四列为函数名称 名称前的缩进用来表示深度

停止调试

  • q 停止调试
  • .detach 分离调试器

区别在于停止调试时调试器和目标程序的偶停止运行,而分离调试器则是调试器显示No Target,而目标程序继续运行

总结

命令 含义 说明
p Step 单步步过
t Trace 单步步入
pa Step to Address 单步到指定地址 不进入子函数
ta Trace to Address 追踪到指定地址 进入子函数
pc Step to Next Call 单步执行到下一个函数调用
tc Trace to Next Call 追踪执行到下一个函数调用
tb Trace to Next Branch 追踪到下一条分支指令
g Go 恢复运行
gu Go Up 执行到函数返回
q Quit 停止调试
.detach detach 分离调试器

使用断点

软件断点

WinDBG设计了三条命令来设置软件断点,分别是bp、bu和bm。其中bp是基本的而且最常用的,其命令格式如下:

bp[ID] [Options] [Address [Passes]] ["Command String"]

  • ID用来指定断点编号 不指定默认从0开始编排
  • Options用来指定选项
  • Address用来指定断点地址
  • Passes用来指定经过断点的次数 默认经过一次断下
  • CommandString用来指定设置断点后执行的命令 用双引号包围命令,多个命令用分号分隔

bu命令用来设置一个延迟的以后再求解的断点,用于对尚未加载模块中的代码设置断点。当指定的模块被加载时,WinDBG会真正落实这个断点。所以bu命令对于调试动态加载模块的入口函数或者初始化代码特别有用

bm命令用来设置一批断点,相当于帮我们自动执行很多次bp或者bu命令。比如以下命令对于msvcr80d模块中的所有print开头的函数设置断点:

bm msvcr80d!print*

bm和bu是命令格式如下:

bu[ID] [Options] [Address [Passes]] ["Command String"]
bm[Options]  SymbolPattern [Passes] ["Command String"]

其中的Options可以为以下内容

  • /1如果指定此选项,那么这个断点命中一次后便会便自动从断点列表中删除。这种断点被称为一次命中断点
  • /p这个开关只能用在内核调试中,/p后跟一个进程的EPROCESS结构,作用是只有当前进程是指定进程时才触发这个断点
  • /t与/p开关类似,只能用在内核调试中,用来指定一个ETHREAD结构,作用是只有在执行指定的线程访问断点地址时才触发断点
  • /c和/C这两个开关后面可以带一个数字,用来指定中断给用户的最大函数调用深度和最小函数调用深度。举例来说,使用命令bp/c5msvcr80d!printf设置的断点只有当函数调用深度浅于5时才中断给用户

硬件断点

WinDBG的ba命令用来设置硬件断点,其格式如下:

ba [ID] Access Size [Option] [Address[Passes]] ["Command String"]

  • ID用来指定断点序号

  • Access用来指定触发断点的访问方式 可以为以下几个字母之一

    • e 读取和执行时触发断点
    • r 读取和写入时触发断点
    • w 写入时触发断点
    • i 有IO操作时触发断点
  • Size用来指定访问的长度 x86系统可以为1 2 4三种值

  • Passes参数和CommandString参数的用法与设置软件断点命令中的一样

例如:

ba r1 0x401000

那么对内存地址0041717c的一字节访问、字访问、双字访问(读写)都会触发这个断点

如果想要查看硬件断点和状态可以直接看寄存器窗口的DR0-DR7寄存器

条件断点

对 没有错!windbg也支持条件断点,但是这玩意有点复杂,而且实用性好像不大,反正我在OD里从来没用过,直接PASS吧。另外windbg好像是没有内存断点的

地址表达式

可以使用以下三种方法来指定断点命令中的地址参数

  • 直接使用内存地址,比如bp 00411390

  • 使用模块名加函数符号的方式,比如bp dbgee!wmain代表对dbgee模块中的wmain函数设置断点。也可以在符号后增加一个地址偏移,比如bp dbgee!wmain+3

  • 如果是使用完全的调试符号,调试符号中包含源代码行信息,那么可以使用如下形式:

    `[[Module!]Filename][:LineNumber]`

其中Module为模块名,Filename为源程序文件名,LInenumber为行号。整个表达式要用两个波浪号包起来``,要有调试符号才能实现,对于逆向来说没什么用 这个也pass

  • 对于C++的类方法,也可以使用类名双冒号(::)或者双下划线(__)来连接类名和方法名,比如:

    bp MyClass__MyMethod
    bp MyClass:MyMethod
    bp@@(MyClass::MyMethod)

    管理断点

使用bl命令可以列出当前已经设置的所有断点,例如:

1554560702285.png

  • 第一列是断点的序号

  • 第二列是断点的状态

    • e代表启用(enable)
    • d代表禁用(disable)
    • 对于bu设置的断点还可能有字母u,表示尚未解决(unresolved)
  • 第三列是断点的地址

  • 第四列是断点触发剩余次数

  • 第五列是断点的初始计数

  • 第六列是断点所关联的进程和线程,冒号前是进程号,冒号后是线程号

  • 第七列是断点地址的符号表示

命令bc、bd、be分别用来删除、禁止和启用断点,它们的格式都是:

bc|bd|be 断点号

其中断点号可以使用*来通配所有断点,使用-来表示一个范围,或者使用逗号来指定多个断点号。例如以下命令都是有效的:

bd 0-2,4  禁止0 1 2 4号断点
be *      启用所有断点

总结

最后来一个表格总结

命令 含义
bp 设置软件断点
bu 对未加载的模块设置断点
bm 批量设置断点
ba 设置硬件断点
bl 列出所有断点
bc 删除断点
bd 禁止断点
be 启用断点

观察栈

显示栈回溯

WinDBG的k系列命令就是用来帮助我们进行栈回溯的,先来看一个例子

1554561435896.png

  • 其中的每一行代表栈上的一个栈帧 也就是一个函数
  • 最上面一行表示的是当前正在执行的函数 每个函数下面一行是上一行的父函数
  • 第一列是栈帧的基地址EBP
  • 第二列是函数的返回地址
  • 第三列是函数名以及执行位置

K命令显示了函数名信息,但是没有显示每个函数的参数。命令kb可以显示放在栈上的前三个参数,例如(L是不显示源文件信息):

1554561861693.png

  • 前两列以及最后一列的内容与k命令结果是一样的
  • 中间三列是函数的参数 只显示三个,如果要观察第四个参数可以用  dd ebp+x014
  • 这只是栈上的前三个参数,如果函数的调用约定为fastcall,那么前两个参数还是在ecx和edx

其他K系列的命令:

  • kp命令可以把参数和参数值都以函数原型格式显示出来,但是需要有符号,对于逆向来说没什么用
  • kv命令可以在kb命令的基础上增加显示FPO信息和调用约定
  • kn命令会在每行前显示栈帧的序号

总结

命令 含义
k 显示调用堆栈
kb 显示调用堆栈和栈上的前三个参数
kp 参数和参数值都以函数原型格式显示出来(必须有符号)
kv kb命令的基础上增加显示FPO信息和调用约定
kn 命令会在每行前显示栈帧的序号

分析内存

显示内存

WinDBG的d系列命令用来显示指定内存区域的数据内容。这些命令的格式为:

d{a|b|c|d|D|f|p|q|u|w|W} [Options] [Range]
dy{b|d} [Options] [Range]
d [Options] [Range]

其中大括号中的字母(区分大小写)用来指定数据的显示方式,含义如下:

  • a表示ASCII码
  • b表示字节和ASCII码
  • c表示DWORD和ASCII码
  • d表示DWORD
  • D表示双精度浮点数
  • f表示单精度浮点数
  • p表示按指针宽度显示
  • q表示四字(8字节)
  • u表示UNICODE字符
  • w表示字
  • W表示字和ASCII码
  • yb表示二进制和字节
  • yd表示二进制和双字

Range参数用来指定要显示的内存范围。可以有以下几种表示方法:

  • 第一种方法是起始地址加空格加终止地址,比如dd 0012fd9c 0012fda8命令以双字格式显示从0012fd9c开始到0012fda8结束的16字节内存数据
  • 第二种方法是起始地址加空格加L(或者1)和对象个数,比如上面的命令可以等价的写为:dd 0012fd9c L4
  • 第三种方式是结束地址加空格加L(或者1)加负号和对象个数。使用这种方式可以把上面的命令写为:dd 0012fdac L-4

显示字符串

可以以0结尾的简单字符串,可以使用da或者du命令来显它的内容,前者用于使用单字节字符集的字符串,后者用于采用UNICODE字符集的字符串。当遇到字符串末尾的0时,会自动停止显示。例如:du 003a2e9c

1554562807596.png

显示数据类型

WinDBG的dt命令用来显示数据类型以及按照类型来显示数据。Dt的含义是Dump symbolic Type information。Dt是个比较复杂的命令,下面我们按照用法分别来介绍

首先,可以使用dt来显示一个数据类型(数据结构)。这种用法的典型格式是:
dt[模块名!]类型名
其中模块名部分可以省略,如果省略,那么调试器会自动搜索所有模块。类型名即程序中定义数据结构或者通过typedef定义的类姓名。类型名中可以包含通配符,比如以下命令会列出NTDLL模块中的所有类型:

dt ntdll!*

如果类型名是确定的类型,那么dt便会显示这个类型的定义,如果类型中还包含子类型,那么可以用-b开关来递归式显示所有子类型,也可以使用-r开关来指定显示深度。-r0表示不显示子类型,-r1表示显示1级子类型,依此类推,例如:

dt -r1 _TEB

如果不想显示整个结构,而只显示某些字段,那么可以在类型名后使用-ny开关附加字段搜索选项,比如以下命令只显示TEB结构的LastError开始的字段:

dt _TEB -ny LastError

Dt命令的第二种用法是在上一种方法的基础上增加内存地址,让dt 按照类型显示指定地址的变量。例如,以下命令使用_PEB结构来显示内存地址7ffdd000出的数据:

dt _PEB 7ffdd000

Dt命令的第三种用法是显示类型的实例,包括全局变量、静态变量和函数。比如以下命令显示dbgee程序的g_szGlobal全局变量:

dt dbgee!*wmain*

搜索内存

S命令用于搜索内存,有三种使用方法。

  • 第一种用法是在指定的内存范围内搜索任何ASCⅡ字符或者UNICODE字符串,其格式如下:

    • s-[[Flags]]sa|su Range
    • Range用来指定内存范围
    • sa用来搜索ASCII字符串 su用来搜索unicode字符串
    • Flag可以指定搜索选项

    例如,以下命令搜索nt!PsInitialSystemProcess变量所指向地址开始的512个字节范围内任何长度不小于5的ASCIⅡ字符串:

    s-[l5]sa poi(nt!)PsInitialSystemProcessl200

  • 第二种用法是在指定内存地址范围内搜索与指定对象相同类型的对象,这里的对象是指包含虚拟函数表的使用面向对象语言(如C++)编写的类(Class)对象。其格式为:

    • s[[Flags]]v Range Object
  • S命令的第三种用法是在指定范围内搜索某一内容模式,其语法格式为:

    • s[-[[Flags]]Type] Range Pattern
    • 其中类型表示搜索的内容的数据类型(宽度),可以为b(字节) d(双字)
    • Pattern参数用来指定要搜索的内容

如果你觉得上面一段话理解起来有点费劲,不妨直接看下面的例子

s 0012ff40 L20 'H' 'e' 'l' 'l' 'o' 
s 0012ff40 L20 48 65 6c 6c 6f 
s -a 0012ff40 L20 "Hello" 

它们都是等效的。意为在0012ff40到0012ff60之间搜索hello字符,-a参数指定以ACSII的方式搜索字符,类似的还有-u,它指定以UNICODE的方式搜索字符

修改内存

命令e用来修改指定内置地址或者区域的内容,

  • 第一种是按字符串编辑,其命令格式为:

    • e{a|u|za|zu}Address "String"
    • 其中Address是要修改的内存的起始地址
    • za代表以0结尾的ASCII字符 zu代表以0结尾的Unicode字符
    • a和u分别代表不是以0结尾的ASCII和Unicode字符
  • 第二种用法是以数值方式编辑,其格式为:

    • e{b|d|D|f|p|q|w} Address [Values]
    • 其中大括号中的字母用来表示要修改的数据类型,也决定要修改的内存方式
    • Address用来指定要修改的内存起始地址
    • Values用来指定新的值

例如:

Eb 00100000 01 02 03 04      数据类型为BYTE
Ed 00100000 0201 0403        数据类型为DWORD
Ea 00100000 ‘hello’          数据类型为ASCII
Eu 00100000 ‘你好’            数据类型为UNICODE

其他命令

下表记录了我个人认为在调试时经常会用到的一些命令,记录的不全,欢迎补充

命令 含义
.attach .attach PID 附加到指定ID的进程
.restart 重启被调试应用
.cls 清理屏幕
.formats 显示数字的各种格式信息
lm 显示模块
.reboot 重启虚拟机
r 查看或修改寄存器
u 显示汇编
.help 查看帮助

参考资料

《windbg用法详解》

WinDbg命令三部曲

从Ollydbg说起-----WinDbg用户态调试教程:https://bbs.pediy.com/thread-34379.htm

点评

改天用AHK映射一个仿OD的热键版。界面像了,热键也得似模似样才行@-@  发表于 2019-4-11 07:49

免费评分

参与人数 18威望 +3 吾爱币 +30 热心值 +18 收起 理由
ls0928 + 1 + 1 热心回复!
风静,天清 + 1 + 1 谢谢@Thanks!
fuckbin + 1 + 1 我很赞同!
chenshiyiya + 1 + 1 谢谢@Thanks!
18889646651 + 1 + 1 谢谢@Thanks!
小朋友呢 + 2 + 1 我很赞同!
frankyy + 1 + 1 热心回复!
zhou220 + 1 + 1 谢谢@Thanks!
166818008 + 1 + 1 我很赞同!
海天一色001 + 1 + 1 谢谢@Thanks!
willJ + 3 + 12 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
公孙秒秒 + 1 + 1 我很赞同!
helica + 1 + 1 谢谢@Thanks!
lookerJ + 1 + 1 热心回复!
ytfrdfiw + 1 + 1 谢谢@Thanks!
z_sy + 1 + 1 感谢分享,非常有用
chenjingyes + 1 + 1 感谢分享 写的好详细
joneqm + 1 + 1 我很赞同!虽然看不懂。

查看全部评分

本帖被以下淘专辑推荐:

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

_左道 发表于 2019-5-29 18:47
条件断点用处很大吧?作为进阶必须熟练的.
而且用起来不复杂,还可以实现一些脚本.
bu 12345678 ".if(eax==1){ r eax =2;}.else{ gc }"

内存断点目前我是用硬件断点实现的,只能下4个确实有局限性
怕猪猪 发表于 2019-11-14 16:28
分析dump的时候 提示 模块加载已完成,但无法为ntoskrnl.exe加载符号 ,符号路径设置的是微软服务器,.reload也试过啦,大佬遇到过这个问题吗
8]4K12ZCTD4B5{]Z`@GW~_I.png
beatone 发表于 2019-4-8 22:03
huahua1998 发表于 2019-4-8 22:31

技术贴,支持
hac11az 发表于 2019-4-8 22:48
写的好详细~
117117 发表于 2019-4-8 23:02
感谢分享,好详细啊
陈世界 发表于 2019-4-8 23:14
感谢楼主的精彩分享了
chenjingyes 发表于 2019-4-9 01:02

感谢分享  写的好详细
tyh2018 发表于 2019-4-9 08:47
谢谢楼主的分享。
psx1lin 发表于 2019-4-9 09:03


技术贴,支持
收藏了
gongjiaojituan 发表于 2019-4-9 09:26
门外汉,看不懂也要谢谢
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则 警告:本版块禁止灌水或回复与主题无关内容,违者重罚!

快速回复 收藏帖子 返回列表 搜索

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - LCG - LSG ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2024-4-20 00:29

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表