吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 3095|回复: 69
收起左侧

[分享] 新人小白记一次经典小游戏"大家来找茬"的资源文件解包过程(文末附C#源码)

[复制链接]
烟99 发表于 2024-10-24 01:05
本帖最后由 烟99 于 2024-10-24 09:08 编辑

尽管担任论坛站务团队多年,但还是字自己新人小白是因为我没啥逆向基础,靠我肚子里的墨水瞎猜搞出来的一个帖子

正文开始。
PS:文章水平有点拉,不喜勿喷
如下图,这款游戏大概是90、00后的童年回忆吧

9207.jpg


没错,这款游戏叫做“5 spots II”,中文译为“大家来找茬 5 Spots II”玩过的同学举爪 Cache_-503a3c925745c8ee.jpg

今天来分享一下游戏的资源文件解包过程
打开游戏根目录下的data文件夹,里面有ad.dat、gfx.dat和sound.dat

7YJQW]0AIXZ`LTN@X3AHQ3K.png

同学们已经想到了,图片文件和声音文件在gfx.dat和sound.dat中,那么恭喜你,答对了,送你两朵大红花 8N07JW}QF4W}38N$DWUYBDO.gif

在开始之前,大家需要了解两样东西
首先是ASCII码各个字符所对应的十六进制值,网上有对照表,这里有一篇CSDN文章,请不了解的同学提前预习

其次是常见图片文件、音频文件的头部的十六进制字节。先介绍文件头部,它也称为文件签名,是文件格式中非常重要的一部分。它位于文件的起始位置,通常包含一系列特定的字节序列,这些序列对于识别文件的类型和版本至关重要。

先说JPG头部,JPG的头部字节通常为FF D8开始
[Asm] 纯文本查看 复制代码
FF D8 FF E0 00 10 4A 46 49 46

尾部为
[Asm] 纯文本查看 复制代码
FF D9


然后说PNG头部,JPG的头部字节通常为
[Asm] 纯文本查看 复制代码
89 50 4E 47 0D 0A 1A 0A


再说说GIF,JPG的头部字节有两种版本,一种是GIF89a另一种是GIF87a,目前GIF89a相对较多
GIF89a:
[Asm] 纯文本查看 复制代码
47 49 46 38 39 61


GIF87a:
[Asm] 纯文本查看 复制代码
47 49 46 38 37 61


最后说一下WAV和OGG头部字节
WAV格式一般为RIFF开头,但是RIFF格式windows环境下大部分多媒体文件遵循的一种文件结构,它有可能是WAV、也有可能是AVI视频、MIDI音频,无法判定,因此,我们还需要判定后面,后面有WAVEfmt,这样才可确定是WAV格式,鉴于此,WAVE的头部字节为
[Asm] 纯文本查看 复制代码
52 49 46 46 24 20 0B 00 57 41 56 45 66 6D 74


OGG就比较好判断了,头部是“OGGs”,十六进制字节就是
[Asm] 纯文本查看 复制代码
4F 67 67 53 S


好了,常见图片音频头部介绍完了

二话不说,我们直接上HEX文本编辑器(这里以010editor为例)
首先来分析gfx.dat
7_PEKLN@4MQ5IZ1LA8Z$)40.png

包文件头部为“VFS2”,这里是游戏厂商定义的资源包头部标识,不用管,向下看
此时我们发现了一些明文,这里正是包文件的内部的文件夹结构,目前还看不出是否为多层级文件夹
QQ截图20241023165708.png
到4D70行的时候才有文件名

QQ截图20241023170221.png
以上都是包文件的目录索引,到了5:D9F0才是数据区,因为索引区一开始都是SPR格式的数据文件,因此只能忽略,往下看浪费时间也没有意义,所以要换个思路。
本人猜测,文件前部分用00填充的比较多,所以包文件数据应该没有压缩,没有加密,也没有异或运算,接下来直接搜关键字吧。


如果图片存放在gfx.dat的话,那就搜一下常见图片格式头部的字节数据。
如图,按Ctrl+F,打开搜索,输入FF D8 FF E0 00 10 4A 46 49 46,然后搜索类型为Hex字节,按回车
QQ图片20241023171758.png

果然,搜到了JPG的头部,共有1502条记录。
%JCC_DH%WB0769L]8)6T~{J.png
这也充分证明了这种格式的游戏资源包文件压缩、加密、异或运算统统无。
但是这并没有结束,文件与文件之间是否有特征字节来分隔开现在还不得而知,因此还要尝试搜一下JPG的尾部字节。
然而,最害怕的事情还是发生了
我在搜索完FF D9后,后面直接又FF D8 FF E0 00 10 4A 46 49 46了
QQ截图20241023173247.png


还真没有特征字节,于是乎,我想起了之前,我猜测它们是记录这些子文件数据长度的。
接着,我随便找一个完整的JPG数据把它高亮圈中。
5T2(L1MROCY[P7_1J1KIG.png
此时状态栏显示的选中部分的长度为4700字节,因此我们可以判断这个JPG文件的大小为3303字节
为了验证我们拷贝的数据是不是一个完整的JPG图片,我们按Ctrl+Shift+N快捷键新建一个Hex文件
QQ截图20241023190633.png


很成功,我们顺利的解包了一个JPG文件
XDXB7$@G~G0$ITXQ2ANY98V.png

IMAGE.JPG

如果说索引区的子文件目录表中还穿插了一些其他数据,这个JPG图片长度是3303字节,转换成十六进制为CE 07,那么就搜一下
然而并没有结果,搜到的首个出现位置根本就不在索引区

6P1B1T87)R(3QNS]}[M_EOO.png

这就难办了,数据与数据之间没有分隔符,索引区的子文件后面的字节信息是什么也不知道,到这里思路戛然而止
58aebbd436fcbee03213ece80cb0bb7d.jpg


这里就需要借助IDA来对EXE进行静态分析了,那么不妨试一下

打开IDA,把文件拖入,开始静态分析。
我猜测EXE肯定会加载包内的JPG图片,于是搜索了一下,果然,搜到了很多关于加载JPG的操作,还是带有文件目录的,这也说明前期的包文件索引区的没有后缀的字符串确实是文件夹名称
EF$M2[K(620M{0MN3LANS`U.png

随后这个地方引起了我的注意
QQ图片20241023221601.png
在..text代码段的00404B38处,push了一点数据"Can't Load 'Background.jpg'",这说明这里是不能加载Background.jpg的提示,而不能加载的原因无非就是文件不存在或者拒绝访问,在这个场合下,拒绝访问的可能性几乎为0,因此,在这里应该能够追踪到关于解包的一些线索
在他的上方call了sub_40582A 函数


到了这里我就没有头绪了,希望大佬解读一下。
JX[KD}0K$HY(JK9UP4{TA{7.png
但是,一码归一码,虽然找不到解包算法,但我们还是可以通过文件头部特征来解包文件
既然我们知道JPG图片的头部和尾部的字节特征,那么我们写一个for循环语句把匹配到的首尾字节中间的部分单独复制出来不就可以了吗?
答案是肯定的!
C语言我不熟,易语言显得水平低,Python我不喜欢(因为严格的缩进要求我很不爽),那就用C#吧

先分析操作流程,首先我们要把游戏包文件读入到byte[]数据,也就是易语言所谓的字节集,然后创建一个for循环操作,循环次数为游戏包文件总长度,循环体进行if判断,只要是匹配到JPG头部字节的就将其拷贝到List<byte[]>中,并返回,最后,创建另一个for循环,循环次数为刚才返回的List<byte[]>成员数,保存文件,完成。
那就写一下代码吧
[C#] 纯文本查看 复制代码
using System;
using System.Collections.Generic;
using System.IO;

namespace UNDAT
{
    internal class Program
    {
        /// <summary>
        /// 程序主函数
        /// </summary>
        /// <param name="args"></param>
        static void Main(string[] args)
        {
            // 我们把所有解包操作封装在一个函数里,因为相关函数有抛出异常的操作,因此需要catch,以防程序意外退出
            try
            {
                UnPackJpegData("gfx.dat");// 解包目标:gfx.dat,请把程序和此文件放在同一个目录下
            }
            catch (Exception e)
            {
                Console.WriteLine(e.ToString());
            }
        }

        /// <summary>
        /// <逻辑型> DAT文件头部检查
        /// <param name="arr">(字节集 被检查的字节集数据 ,</param>
        /// <param name="target">字节集 欲检查的字节集数据)</param>
        /// <returns><para>成功返回真,否则返回假</para></returns>
        /// </summary>
        private static bool HeadCheck(byte[] arr, byte[] target)
        {
            // 先看看它是不是太短
            if (arr.Length < 4 || target.Length != 4)
            {
                return false;
            }

            // 对两边的每一个元素的成员内容逐一检查,只要有不同就返回假
            for (int i = 0; i < 4; i++)
            {
                if (arr[i] != target[i])
                    return false;
            }

            // 条件满足,返回真
            return true;
        }

        /// <summary>
        /// 解包指定文件
        /// <param name="filePath">(文本型 欲解包的DAT文件)</param>
        /// <exception cref="Exception"><para>字节不匹配将抛出异常</para></exception>
        /// </summary>
        private static void UnPackJpegData(string filePath)
        {
            // 判断文件是否存在
            if (!File.Exists(filePath))
            {
                throw new Exception("对不起,所选定的文件不存在!!");
            }

            // 读入字节集数组
            byte[] byteArray = File.ReadAllBytes(filePath);

            // 先对DAT文件头部检查,看看他到底是不是这个游戏的DAT文件
            // DAT的头部是以“VFS2”开头的,先把他们写进一个字节集数组中
            byte[] headByte = { 0x56, 0x46, 0x53, 0x32 };

            // 开始对头部进行判断,如果头部不匹配,那么就认定它不是这个游戏的DAT文件,并抛出一个异常
            bool isGameDATFile = HeadCheck(byteArray, headByte);
            if (!isGameDATFile)
            {
                throw new Exception("对不起,所选定的文件不是游戏:5 spots II的DAT资源包文件!");
            }

            // 创建一个List<byte[]> ,用于存放已识别到的JPG图片
            List<byte[]> jpgs = ExtractJpgsFromBytes(byteArray);

            // 创建一个解包文件夹,格式为:“文件名+拓展名+UnPacked”
            string fileNameWithExt = Path.GetFileName(filePath);
            string extension = Path.GetExtension(fileNameWithExt);
            string fileNameWithoutExt = Path.GetFileNameWithoutExtension(fileNameWithExt);
            string unPackDirectory = fileNameWithoutExt + "_" + extension + "_UnPacked";
            Directory.CreateDirectory(unPackDirectory);

            // 保存每个JPEG为独立的文件,文件名格式:image_xxxxxxxx.jpg
            for (int i = 0; i < jpgs.Count; i++)
            {
                string formattedIndex = i.ToString("D8");
                SaveJpgToFile(jpgs[i], unPackDirectory + "\\image_" + formattedIndex + ".jpg");
            }
        }

        /// <summary>
        /// <List字节集>解包JPG文件
        /// <param name="bytes">(字节集 欲处理的数据)</param>
        /// <returns><para>成功返回处理好的List字节集</para></returns>
        /// </summary>
        public static List<byte[]> ExtractJpgsFromBytes(byte[] bytes)
        {
            // 创建一个List<byte[]>用于存放返回的JPG数据
            List<byte[]> jpgList = new List<byte[]>();

            // JPEG数据开始标记
            bool inJpeg = false;

            // JPEG数据起始位置
            int start = -2;

            // 开始for循环
            for (int i = 0; i < bytes.Length - 1; i++)
            {
                // 当匹配到Hex字节FF和D8,置起始标记位为真,并记录起始位置
                if (bytes[i] == 0xFF && bytes[i + 1] == 0xD8)
                {
                    inJpeg = true;
                    start = i;
                }
                // 当匹配到Hex字节FF和D9,置起始标记位为假,复制到jpgList中
                else if (inJpeg && bytes[i] == 0xFF && bytes[i + 1] == 0xD9)
                {
                    inJpeg = false;
                    byte[] jpgData = new byte[i - start + 2];  // 欲复制的长度公式为:数据总长 - 起始位置 + 2
                    Array.Copy(bytes, start, jpgData, 0, i - start + 2);
                    jpgList.Add(jpgData);
                }
            }
            // 返回解出的JPG
            return jpgList;
        }

        /// <summary>
        /// 保存JPG图片
        /// <param name="bytes">(字节集 欲保存的JPG数据</param>
        /// <param name="fileName">文本型 欲保存的文件名称)</param>
        /// </summary>
        public static void SaveJpgToFile(byte[] bytes, string fileName)
        {
            // 转成文件流,然后写到二进制流,最后保存文件
            FileStream fs = new FileStream(fileName, FileMode.Create);
            BinaryWriter writer = new BinaryWriter(fs);
            writer.Write(bytes);
            writer.Close();
            fs.Close();
        }
    }
}




写完编译,完美运行! 427bf985eb20e0e0df84c143388e00c5.jpg
{A@H%TW77%JV[2HQ8I3DL`O.png

当然这只是匹配到符合条件的JPG,还有一些JPG只有头部,没有尾部,这部分是识别不到的。
至于带文件名、带文件夹的完美解包,我卡在了exe静态分析中,回头我的把我的微机原理那本书找出来复习一下
也希望大佬给个关于完美解包的分析思路,可开悬赏
写代码、写文章不易,还请同学们给点鼓励,鉴于sound.dat也是明文无压缩无加密的,最后丢一个解包sound.dat的代码吧,匹配机制为RIFF,回帖可看,五天后自动解除。

[C#] 纯文本查看 复制代码
using System;
using System.Collections.Generic;
using System.IO;

namespace UNDAT
{
    internal class Program
    {
        /// <summary>
        /// 程序主函数
        /// </summary>
        /// <param name="args"></param>
        static void Main(string[] args)
        {
            // 我们把所有解包操作封装在一个函数里,因为相关函数有抛出异常的操作,因此需要catch,以防程序意外退出
            try
            {
                UnPackJpegData("sound.dat");// 解包目标:sound.dat,请把程序和此文件放在同一个目录下
            }
            catch (Exception e)
            {
                Console.WriteLine(e.ToString());
            }
        }

        /// <summary>
        /// <逻辑型> DAT文件头部检查
        /// <param name="arr">(字节集 被检查的字节集数据</param>
        /// <param name="target">字节集 欲检查的字节集数据)</param>
        /// <returns><para>成功返回真,否则返回假</para></returns>
        /// </summary>
        private static bool HeadCheck(byte[] arr, byte[] target)
        {
            // 先看看它是不是太短
            if (arr.Length < 4 || target.Length != 4)
            {
                return false;
            }

            // 对两边的每一个元素的成员内容逐一检查,只要有不同就返回假
            for (int i = 0; i < 4; i++)
            {
                if (arr[i] != target[i])
                    return false;
            }

            // 条件满足,返回真
            return true;
        }

        /// <summary>
        /// 解包指定文件
        /// <param name="filePath">(文本型 欲解包的DAT文件)</param>
        /// <exception cref="Exception"><para>字节不匹配将抛出异常</para></exception>
        /// </summary>
        private static void UnPackJpegData(string filePath)
        {
            // 判断文件是否存在
            if (!File.Exists(filePath))
            {
                throw new Exception("对不起,所选定的文件不存在!!");
            }

            // 读入字节集数组
            byte[] byteArray = File.ReadAllBytes(filePath);

            // 先对DAT文件头部检查,看看他到底是不是这个游戏的DAT文件
            // DAT的头部是以“VFS2”开头的,先把他们写进一个字节集数组中
            byte[] headByte = { 0x56, 0x46, 0x53, 0x32 };

            // 开始对头部进行判断,如果头部不匹配,那么就认定它不是这个游戏的DAT文件,并抛出一个异常
            bool isGameDATFile = HeadCheck(byteArray, headByte);
            if (!isGameDATFile)
            {
                throw new Exception("对不起,所选定的文件不是游戏:5 spots II的DAT资源包文件!");
            }

            // 创建一个List<byte[]> ,用于存放已识别到的WAV音频
            List<byte[]> riffChunks = ExtractRiffChunks(byteArray);

            // 创建一个解包文件夹,格式为:“文件名+拓展名+UnPacked”
            string fileNameWithExt = Path.GetFileName(filePath);
            string extension = Path.GetExtension(fileNameWithExt);
            string fileNameWithoutExt = Path.GetFileNameWithoutExtension(fileNameWithExt);
            string unPackDirectory = fileNameWithoutExt + "_" + extension + "_UnPacked";
            Directory.CreateDirectory(unPackDirectory);

            // 保存每个WAV为独立的文件,文件名格式:sound_xxxxxxxx.wav
            for (int i = 0; i < riffChunks.Count; i++)
            {
                string formattedIndex = i.ToString("D8");
                SaveRiffChunkToFile(riffChunks[i], unPackDirectory + "\\sound_" + formattedIndex + ".wav");
            }
        }


        /// <summary>
        /// <List字节集> 解包WAV音频
        /// <param name="bytes">(字节集 欲处理的数据)</param>
        /// <returns><para>成功返回带WAV数据的List</para></returns>
        /// </summary>
        public static List<byte[]> ExtractRiffChunks(byte[] bytes)
        {
            // 创建一个List<byte[]>用于存放返回的WAV数据
            List<byte[]> riffList = new List<byte[]>();

            // WAV头部判断依据为RIFF
            byte[] riffHeader = new byte[] { 0x52, 0x49, 0x46, 0x46 };
            
            // WAV数据开始标记
            bool inRiff = false;

            // WAV数据起始位置
            int start = -4;


            for (int i = 0; i < bytes.Length - 3; i++)
            {
                // 当匹配到RIFF,置起始标记位为真,并记录起始位置
                if (IsRiffHeader(bytes, i))
                {
                    inRiff = true;
                    start = i;
                }
                // 匹配结束位置,复制到jpgList中,置起始标记位为假
                else if (inRiff && IsRiffHeader(bytes, i + 1))
                {
                    byte[] riffData = new byte[i - start];
                    Array.Copy(bytes, start, riffData, 0, i - start);
                    riffList.Add(riffData);
                    inRiff = false;
                }
            }
            // 返回已处理的WAV
            return riffList;
        }

        /// <summary>
        /// <逻辑型> 检查是否为RIFF开头
        /// <param name="bytes">(字节集 欲检查的数据 ,</param>
        /// <param name="offset">整数型 欲检查的位置)</param>
        /// <returns><para>匹配成功返回真,否则返回假</para></returns>
        /// </summary>
        public static bool IsRiffHeader(byte[] bytes, int offset)
        {
            return bytes[offset] == 0x52 &&
                   bytes[offset + 1] == 0x49 &&
                   bytes[offset + 2] == 0x46 &&
                   bytes[offset + 3] == 0x46;
        }

        /// <summary>
        /// 保存WAV
        /// <param name="bytes">(字节集 欲保存的数据 ,</param>
        /// <param name="fileName">整数型 欲保存的文件名称)</param>
        /// </summary>
        public static void SaveRiffChunkToFile(byte[] bytes, string fileName)
        {
            // 先转文件流,在转二进制流,最后存盘
            using (FileStream fs = new FileStream(fileName, FileMode.Create))
            using (BinaryWriter bw = new BinaryWriter(fs))
            {
                bw.Write(bytes);
            }
        }
    }
}





免费评分

参与人数 13威望 +1 吾爱币 +32 热心值 +11 收起 理由
lingyq + 1 + 1 谢谢@Thanks!
Hmily + 1 + 20 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
吾恋兮不知 + 1 用心讨论,共获提升!
gbx2000 + 1 用心讨论,共获提升!
yxnwh + 1 + 1 谢谢@Thanks!
tb612443 + 1 + 1 谢谢@Thanks!
timeni + 1 + 1 用心讨论,共获提升!
HillBoom + 1 + 1 用心讨论,共获提升!
kingty_x + 1 + 1 你说这是“瞎猜”的?闹呢 XD
我今天是大佬 + 1 + 1 热心回复!
realma2014 + 1 + 1 用心讨论,共获提升!
SherlockProel + 1 + 1 热心回复!
laozhang4201 + 1 + 1 热心回复!

查看全部评分

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

爱飞的猫 发表于 2024-10-24 06:07

单纯分析 dat 文件也能做解析器。

数据目录里有个 ad.dat,应该从它开始分析,比较容易上手。

笔记

56 46 53 32 "VFS2"
05 00 00 00 目录数量
05 00 00 00 文件数量

00 00 00 00 ??
94 02 00 00 目录列表大小
20 03 00 00 文件列表大小

// 目录列表 (id 从 1 开始)
00 00 00 00 "classic"
00 00 00 00 "demo"
...

// 文件列表
01 00 00 00 (dir id = 1)
char[0x80] "ad.txt"
CC 05 00 00  0x5cc - file start
1C 02 00 00  0x21c - file size
1C 02 00 00  0x21c - file size 2?
00 00 00 00  unused?
00 00 00 00  unused?
00 00 00 00  unused?
00 00 00 00  unused?

02 00 00 00 parent
char[0x80] "ad.txt"
E8 07 00 00  0x7e8
1D 00 00 00  0x1d
1D 00 00 00  0x1d
00 00 00 00  unused?
00 00 00 00  unused?
00 00 00 00  unused?
00 00 00 00  unused?

后同 ...

代码仓库 (Python) - https://github.com/FlyingRainyCats/bfg_vfs2

点评

我尝试过,但是我不知道TXT怎么断开,所以就放弃了  详情 回复 发表于 2024-10-24 06:44

免费评分

参与人数 2吾爱币 +8 热心值 +2 收起 理由
涛之雨 + 5 + 1 用心讨论,共获提升!
烟99 + 3 + 1 用心讨论,共获提升!

查看全部评分

yanyongliang 发表于 2024-10-24 21:26
lies2014 发表于 2024-10-24 02:17
zwgnhw131499 发表于 2024-10-24 03:15
学习一下新人小白记一次经典小游戏
8672089 发表于 2024-10-24 05:14
谢谢分享了
zj23308 发表于 2024-10-24 06:24
感谢分享,写得真好
 楼主| 烟99 发表于 2024-10-24 06:44
爱飞的猫 发表于 2024-10-24 06:07
[md]单纯分析 dat 文件也能做解析器。

![](https://imgsrc.baidu.com/forum/pic/item/9358d109b3de9c825 ...

我尝试过,但是我不知道TXT怎么断开,所以就放弃了

点评

找文件属性(文件名)附近的 4 字节数据,通常会有一个值代表它的长度。 例如 ads.dat 里,ad.txt 附近有两个值 0x5cc 和 0x21c,尝试一下偏移和文件长度(也有一些喜欢用结束位置),发现结束位置刚好也是单词结束  详情 回复 发表于 2024-10-24 07:23
爱飞的猫 发表于 2024-10-24 07:23
本帖最后由 爱飞的猫 于 2024-10-24 07:24 编辑
烟99 发表于 2024-10-24 06:44
我尝试过,但是我不知道TXT怎么断开,所以就放弃了

找文件属性(文件名)附近的 4 字节数据,通常会有一个值代表它的长度。
例如 ads.dat 里,ad.txt 附近有两个值 0x5cc 和 0x21c,尝试一下偏移和文件长度(也有一些喜欢用结束位置),结束位置刚好也是换行符后面。
然后再看第二个文件,两个相邻的文件的数据也刚好连在一起。
合理猜测一下,这大概率就是存储的格式了。

点评

不过还是有一个地方没搞明白,为什么前面几个文件的文件名字后面有其他字节,还有一些靠前的文件夹也是这样 [attachimg]2731094[/attachimg] [attachimg]2731095[/attachimg]  详情 回复 发表于 2024-10-24 17:09
今天家里比较忙,刚闲下来,研究了一些,我大概知道层叠文件夹的算法了,大概就是把所有文件夹名字从1开始列举,然后如果哪个文件夹前面的一个四字节有数据,那么就表示它的所在文件夹的编号  详情 回复 发表于 2024-10-24 15:52

免费评分

参与人数 1吾爱币 +10 热心值 +1 收起 理由
烟99 + 10 + 1 感谢指导,回头我再试一下

查看全部评分

lxuzhenguo 发表于 2024-10-24 08:02

谢谢分享!
Lty20000423 发表于 2024-10-24 08:06
非常优秀的帖子
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

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

GMT+8, 2024-11-22 17:30

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

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