查看: 1406|回复: 12
收起左侧

[原创分析] 再探UCPD.sys,个人的完整分析,以及几点疑问

  [复制链接]
ANY.LNK
发表于 4 天前 | 显示全部楼层 |阅读模式
本帖最后由 ANY.LNK 于 2025-9-29 00:00 编辑

受前不久的某篇报告和某些媒体的影响,难以继续原定的计划,干扰到了我的正常生活历程,遂对其进行了一个几乎完整的逆向。

分析用的文件本体和数据库放在文章末尾。

样本版本:4.4.0.0

样本哈希(SHA256):D0AD4BA2DB697DDAFCD44F98A4DE2ACB9C0749586F8792B440732896A3F7E81B

分析工具:Binary Ninja Free 4.2.6455 Stable,部分参与:Microsoft Copilot, DeepSeek R1, 纳米AI, 密塔AI等大语言模型

(由于是免费版本,不能像https://mp.weixin.qq.com/s/suFU765TtC7pknVszD_XeQ那样直接接入LLM扩展,因此大部分内容仍依赖于我自己的分析,仅在涉及复杂计算没有明显标识的地方引入LLM辅助分析,可能存在错误,若发现恳请诸位帮忙纠正)

下面是具体的分析内容:


Ⅰ-驱动初始化

从DriverEntry开始,首先进行安全检测防止栈溢出,随后进入主流程



驱动基于Wdf构建,若驱动对象为空则进入驱动加载流程,否则进入卸载流程


获取一系列系统信息



其中InitRegChangeNotifyRoutineObject创建一个内核线程,用于监测注册表UCPD服务相关项,可能用于动态更新配置



InitFeatureConfig从注册表的FeatureV2下读取配置,存入全局变量FeatureV2ConfigFlags



这里虽然通过两种方式进行了设备是否位于中国的的判断,但最后的结果在计算时会被清除。在当前的情况下,无论如何,FeatureV2ConfigFlags对应于0x109的位都会被清除。这可能是为了检测正在开发中的函数是否可用或留作将来备用。

最后生成一份有关于驱动配置加载情况的ETW汇报(微软使用了自定义结构体,目前看来与特定地域开启强制数据收集无关)



RegistryChangeKeyNotifyRoutineThread是实际上的监测注册表更改并处理的函数





若更新的内容为REG_DWORD,则将更新后的配置指针存入FeatureV2ConfigFlags,并生成有关配置情况的ETW日志





若更新内容为REG_SZ且配置开关0x1a被设置,则进入DecodePEAndConfigsInDev



此函数首先将读取到的Base64 Unicode转为ANSI,并转为二进制文件,



接着解析PE结构,











分段加载到内存,验证有效性







验证完毕的各个PE段存储在arg3中返回,然后计算哈希SHA256







返回的SHA256继续进行校验确认是否为可信发布者(话说这里是我的问题还是微软写错了?如果我没看错的话这里将计算出的哈希作为唯一的参数传入了CiCheckSignedFile?这函数确认能这么用?)








(从ci.dll导出的CiCheckSignedFile一共有8个参数,用法如下)

  1. extern "C" __declspec(dllimport) NTSTATUS _stdcall CiCheckSignedFile(
  2.     const PVOID digestBuffer,
  3.     int digestSize,
  4.     int digestIdentifier,
  5.     const LPWIN_CERTIFICATE winCert,
  6.     int sizeOfSecurityDirectory,
  7.     PolicyInfo* policyInfoForSigner,
  8.     LARGE_INTEGER* signingTime,
  9.     PolicyInfo* policyInfoForTimestampingAuthority);
复制代码


更新,这里实际上为转为C伪代码时的BUG,汇编后实际调用类似于

  1. if (r9 < rcx) {
  2.     goto failure;
  3. }

  4. eax = *(DWORD*)r9;  // Read 4 bytes from r9

  5. if (eax <= 0x0C) {  // Check if value is ≤ 12
  6.     goto failure;
  7. }

  8. if (*(WORD*)(r9 + 4) != 0x200) {  // Check if next 2 bytes equal 512
  9.     goto failure;
  10. }

  11. if (*(WORD*)(r9 + 6) != r11w) {  // Compare next 2 bytes with r11w
  12.     goto failure;
  13. }

  14. // Prepare arguments for CiCheckSignedFile
  15. arg1 = &var_58;
  16. arg2 = &var_60;
  17. arg3 = &var_90;
  18. arg4 = r10;
  19. arg5 = eax;  // Structure size


  20. CiCheckSignedFile(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8);
复制代码


对PE的处理在此函数内就告一段落,下面的是一个我这里命名为ObfuseMD5XORCryptConfigs的函数,此函数的主要通过MD5XOR等算法对潜在的配置数据进行加密/解密,可能用于生成抗破解的备份/校验用数据,也可能用于解密已加密的配置数据,或许是对此前出现的对默认浏览器配置的加密数据逆向破解并修改的情况的回应





ObfuseMD5XORCryptConfigs的一阶段是调用ConvertMD5XOR,对输入参数arg[1](即ObfuseMD5XORCryptConfigs的arg3,DecodePEAndConfigsInDev传入的变量var_28[2])进行四轮运算处理





每轮对arg[1]的不同64位段进行XOR操作,调用XORWithMD5对新生成的数据进行MD5计算,用自定义算法、硬编码的种子和MD5值进行两轮处理并将两次的结果进行异或处理并返回掩码(其中运算结果填充掩码的前四字节,后四字节由未知局部变量var_44和var_3c决定)



算法1:





算法2:





完成后,再次循环对ConvertMD5XOR输出的结果进行XORWithMD5运算:



接着对每个掩码和缓冲区中的对应字节进行异或运算



每次处理8字节的一块,直至处理完成。

但最后只返回了用于判断函数是否执行成功了的result,最终的计算结果rdi被忽略没有返回,同时,作为局部传入变量的var_28(就目前来看,如此,不排除以后重新填充的可能)也没有找到赋值的地方

此变量最终传递给了全局变量pMD5XORedConfig,在部分的ETW生成事件时作为被记录的一部分使用,推测可能与云配置有关

DispatcherConfig函数在一系列的验证后在分配的全局表内存中依据ID查找对应函数指针,并以如下伪代码形式调用-处理传入的参数:

  1. int64_t Dispatcher(int32_t context, int32_t* buffer, int32_t bufferSize) {
  2.     int32_t* entryPtr = &buffer[1];  // Skip the entry count
  3.     uint32_t entryCount = buffer[0];

  4.     // Validate input
  5.     if (!buffer || bufferSize < 5 || entryCount < 1 || entryCount > 999)
  6.         return ERROR_INVALID_PARAMETER;

  7.     // Check buffer is large enough for all entries
  8.     if (bufferSize < entryCount * 13)
  9.         return ERROR_INVALID_PARAMETER;

  10.     for (int i = 0; i < entryCount; i++) {
  11.         // Bounds check for entry header
  12.         if (entryPtr + 3 > buffer + bufferSize)
  13.             break;

  14.         uint32_t handlerId = entryPtr[0];
  15.         uint32_t payloadSize = entryPtr[2];

  16.         // Validate handler ID and payload size
  17.         if (handlerId < 1 || handlerId > 999 || payloadSize == 0)
  18.             break;

  19.         // Bounds check for payload
  20.         if (entryPtr + 3 + payloadSize > buffer + bufferSize)
  21.             break;

  22.         // Lookup handler function
  23.         FunctionPointer handler = HandlerTable[handlerId];
  24.         if (handler)
  25.             handler(context, &entryPtr[3]);  // Call handler with payload

  26.         // Move to next entry
  27.         entryPtr += payloadSize + 3;
  28.     }

  29.     return SUCCESS;
  30. }
复制代码


若上述任意环节出错,则转入异常处理流程ETWCloudConfigCcVerFailedPhase,记录下出错的环节并生成ETW日志

此函数目前尚存在诸多问题,包括未赋值的变量,不正确的函数参数传递调用以及下发的注册表配置不正确(解码后非有效PE格式),该分支判断目前不具有除了报错以外的功能,应该为尚处于开发测试中,且为尚不确定未来是否会实际加入的功能。

但可以大致猜测其功能。未使用的变量var_28可能为FeatureV2ConfigFlags标识出的扩展配置,驱动本身不具有解析实现其对应功能的函数,此时加载扩展,并从这个扩展库中读取解析对应的函数进行操作处理。
当前此驱动还不具备实际的从外部加载其他PE的实际代码,但在DispatcherConfig中有从外部库引入函数处理的代码表明,如若加载,应该是加载到pAllocatePoolForExtension(data_14001e738)指针指向的内存池(分配这个池的历程只有驱动完全加载完成后才会启动,后续会提到)

完成后,获取系统API,注册并启动minifilter


此后进入核心历程的初始化,首先初始化进程相关结构体



获取硬编码的注册表路径,键值,特定的黑名单进程名称



配置



插入泛型表







解析





接着创建在进程创建和加载模块时用于更新进程列表的结构



将新增加的进程插入AVL表



在进程加载新的模块的时候将模块加入AVL表




Ⅱ-注册表更改回调

这也是整个驱动最核心的部分



对如下操作进行检测



-删除键
-修改键值
-删除键值
-重命名键值
-设置键的安全属性

对每种操作的处理逻辑大同小异,监控的具体键值略有不同。但大体都遵循如下逻辑:

1.判断进程是否受信任,及特定配置是否开启



若不满足条件,返回ACCESS_DENIED

两种条件进程的行为会被放行:

(1)进程为系统内核进程(System,PID=4)
(2)进程满足不在黑名单内、在白名单内、签名证书受信任、OriginalFileName被允许



其中对原始文件名检测的函数逻辑尚处于开发阶段,目前只做到了解析OriginalFileName字段的逻辑,并未对实际内容进行任何的提取或判断,此函数总是会返回True,因此实际进行检测的只有前三个判断



2.生成修改的ETW日志(只要出现了对应的操作就会生成)



对一系列中国软件证书的额外检测逻辑包含在ProcNativeCallbackModify函数中,它的上游函数几乎都是对默认浏览器的操作(以及一个任务栏操作)



判断函数支持动态列表和静态列表,其中静态列表是22个硬编码的证书名称





(注:由于对中文的证书名称解析存在问题,部分列表条目显示为空。未能显示的条目包括“珠海市君天电子科技有限公司”等,对应的软件包括360、腾讯电脑管家、鲁大师、WPS Office、迅读PDF等,可自行查阅这些软件在网络上的风评)

动态列表规则也同样由栈回溯规则提供,由ParseRuleStackTrace的arg2传入



若匹配上了证书规则,继续获取目标对象名称



获取到之后继续进行下一层判断(这里的var_188值定义为1,因此不会直接进入这个ETW生成分支,唯一的进入此逻辑的方式就是通过label跳转)



进入这个label的方式是地区为中国(由RCodeCache指示)且生成的随机数能够整除100





这也是该驱动的地区判断唯一一次实际采用的地方,就此判断而言,不能复现此驱动的“依据地区强制开启数据上报”

label的内容为ETW额外记录一次特定模块修改默认浏览器等配置被拒绝的事件



Ⅲ-进程权限回调

核心部分之后,是第二层逻辑,用于动态更新规则列表,以及对一些特定的软件采取额外操作



CreateRegUpdateRoutine - 内容类似于InitRegChangeNotifyRoutineObject

InitRuleList - 加载一般的AllowList和DenyList

SetSpecificBrowserRules - 要采取特殊操作的浏览器列表

目前共两款4个条目



QQ浏览器、Opera浏览器,这些浏览器都实现了自己解析默认浏览器配置并修改的功能,绕过用户在“设置”应用中的配置



SetAntiRogueInjectRules - 对于一些会注入白名单进程(如explorer.exe或msedge.exe)的软件额外设立的规则

此规则列表较为复杂,包含

-发起注入的进程路径
-发起注入的进程名称
-被注入的进程名称
-发起注入的进程签名



(末尾的数字是每组此类别规则列表的条目数量)



InitStackTraceRule - 加载栈回溯规则

AddScheduleCheckDefaultToRoutineList - 设立一个计时器,在操作系统启动后有进程创建的时候定期检查指定的配置是否与通过OpenWith.exe设置的是否一致,同时更新进程列表











若发生变更,则重置回默认值,并生成ETW日志





第二部分的核心




如果发现*匹配规则的进程*或*explorer.exe、msedge.exe正在被注入*





(1)移除名单中进程和发起注入的进程的THREAD_SET_CONTEXT、PROCESS_VM_WRITE的权限



(2)将UI Automation相关的0x20位(DESKTOP_JOURNALPLAYBACK)设置为0xffffffdf,AIJDesktop(这是什么?)的0x20位设置为0xfffffff7,相当于阻止利用了





(3)依据情况生成完整的日志

Ⅳ-回调/驱动的卸载和清理

在出现异常/收到指令/需要卸载驱动的情况,将所有的列表清空,资源释放,卸载回调、过滤器和驱动本身





Ⅴ-总结

基于以上分析,目前暂未发现此驱动有超过其描述“User Choice Protection”的界限。

当前版本涉及PE解析部分的代码存在许多问题,尚不具有实际可执行性(不具有加载执行外部未知PE的能力),但已经实现的部分代码的确包含验证外部库、从外部库引入调用额外的函数处理变量(目前还未有充足的定义和赋值)的部分。
此部分代码质量让我有些怀疑微软是否真的有明确的开发计划加入此功能,这些只能等到未来版本才能给出答案。
目前AI说这种动态加载在各软件(比如游戏反作弊、安全软件、硬件驱动开发等)还算常见,此说法尚未得到我的验证

相较于前人的分析,此分析

·否认了https://mp.weixin.qq.com/s/63wAttTVfeMY-y11MTKigA中提到的“驱动自动开启监控功能”“拦截中国安全软件的保护”“阻止中国软件执行”的理论
·纠正了https://mp.weixin.qq.com/s/suFU765TtC7pknVszD_XeQ对于检查是否为可信进程的描述(作者把黑名单和白名单的判断函数写反了,那个if分支下的rbx=0其实是可以NOP掉的,因为默认就是0,此处进行的判断主要是为了决断能否进入嵌套的下一个分支)
·更新了https://binary.ninja/2025/03/25/default-browser-upcd.html中对于特定软件的操作现已升级为“或”,即满足任意其一条件就会触发移除权限的操作,降低了对象的区分度,无需等到特定软件开始注入或调用功能时再移除权限(个人认为确实应该如此,没有暂停目标进程或暂时阻止目标操作的操作,代码执行需要时间,可能移除权限时对方已经完成注入和其他操作了)

其余部分,涉及到云配置,这便是UCPDMgr.exe的内容了,在此不进行过多赘述。

Ⅵ-分析样本及相关数据库

https://wwxl.lanzoul.com/izisH373n5qd,访问密码:bdaz,压缩包密码:infected



PS:折腾了一周,总算能回去写二创了,不知道还能不能接的上。



本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有帐号?快速注册

x

评分

参与人数 17经验 +60 魅力 +2 人气 +68 收起 理由
RainCloud9 + 3 版区有你更精彩: )
ryan_song + 2 精品文章
samtogo + 3 版区有你更精彩: )
QVM360 + 2 版区有你更精彩: )
km_xyx + 3 版区有你更精彩: )

查看全部评分

axeaaa
发表于 4 天前 | 显示全部楼层
本帖最后由 axeaaa 于 2025-9-27 05:49 编辑

好长!深度好文!!!手动点赞!  

希望楼主持续输出好文呀~

一点点补充:  
如果我没看错的话这里将计算出的哈希作为唯一的参数传入了CiCheckSignedFile?

最近刚好在学习密码学的东西,这个逻辑是比较典型的“签名-认证流程”,看来是没有毛病的:  

1. 算出的哈希值作为原内容本身的“简化摘要”,避免直接对大数据块加密的高消耗,这样可以达到提高效率的目的(不管原始数据)  

2. 主楼贴的 CiCheckSignedFile 方法参数名也表明就是对摘要 "digest" 的处理,理解为将摘要加密,输出一个“数字签名”给下游  
  1. extern "C" __declspec(dllimport) NTSTATUS _stdcall CiCheckSignedFile(
  2.     const PVOID digestBuffer,
  3.     int digestSize,
  4.     int digestIdentifier,
  5.     const LPWIN_CERTIFICATE winCert,
  6.     int sizeOfSecurityDirectory,
  7.     PolicyInfo* policyInfoForSigner,
  8.     LARGE_INTEGER* signingTime,
  9.     PolicyInfo* policyInfoForTimestampingAuthority);
复制代码

3. 下游解密时,还需要配合收到的原内容本身,对原内容做相同的哈希拿到 SHA256 结果,将结果和“数字签名”解密得到的内容比对,看它们的一致性  

整这么复杂其实只为了保证两点:传输的内容未被非法篡改 + 签名确实来自可信的上游  


另外估计巨硬员工的代码水平和管理也是参差不齐,毕竟这么大企业,再看管理层的情况……有些功能做着做着就没了,这种情况说不定早已司空见惯了  

评分

参与人数 2经验 +20 人气 +6 收起 理由
ryan_song + 3 精品文章
QVM360 + 20 + 3 版区有你更精彩: )

查看全部评分

DisaPDB
发表于 4 天前 | 显示全部楼层
支持,顺便再复述一遍我之前在毒组群里提过的观点:
这根本上就是一场微软对国内某些流氓厂商的黑吃黑
你以为微软很干净?在我看来,这个所谓的"检验和保护的object回调已经超出了所谓"保护篡改"的范畴。
首先,微软如果只是想要阻止流氓软件篡改默认浏览器或者其他默认程序,完全可以直接通过驱动本地规则+签名匹配进行阻止,而不是像图里这样:


判断地区进行不同的规则下发?这有必要吗?你写etw,根据修正数据生成上报日志可以理解,但是为什么要根据地区进行检测?
第二,关于代码质量:
各位应该还记得前几年的火绒误杀事件,那个时候微软直接把拦截目录硬编码进了explorer里。
这次学聪明了,懂得把拦截目标存储起来用指针读取捏——然后还是硬编码。
对不起兄弟,太几把好笑了:


你说你云控配置都能加密上传了,你说你ucpd主要功能都是云控判断了,为什么不能多留个心眼把这个list也内存解密一下呢?就这么懒?
当然啦,站在维护软件厂商的角度我当然可以说UCPD.sys很恶心,是微软官方自营的“流氓应用”,让软件厂商直能把软件装到电脑上,但是不能轻易的替换掉“用户默认应用”,降低了软件被使用、打开、选择的权利。你甚至可以说微软坏,为了它的Edge它单独在系统上加了这么个垃圾驱动(关键是你觉得你edge现在做得好吗?但凡好点我都不至于这么火大。)

(图片为ucpd中的检测和验证回调)

但是站在用户的角度,在应用-默认应用管理可以手动对这些软件进行选择,它确实又把选择权还给了用户。
最后,我想说的是:
多点自己的判断力,少看国内无良自媒体!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有帐号?快速注册

x

评分

参与人数 3经验 +20 人气 +8 收起 理由
Komeiji-Reimu + 3 感谢解答: )
QVM360 + 20 + 2 版区有你更精彩: )
ANY.LNK + 3 感谢支持,欢迎常来: )

查看全部评分

aikafans
发表于 4 天前 | 显示全部楼层
啥都不说了,支持
ANY.LNK
 楼主| 发表于 4 天前 | 显示全部楼层
axeaaa 发表于 2025-9-27 05:22
好长!深度好文!!!手动点赞!  

希望楼主持续输出好文呀~

主要是……CiCheckSignedFile的调用方式不对,依据这里的逆向,这个函数是要填8个参数的,其中5个传入,3个传出,单参数会报告调用的参数过少

  1. NTSTATUS CiCheckSignedFile(
  2.     __In__ const PVOID digestBuffer,
  3.     __In__ int digestSize,
  4.     __In__ int digestIdentifier,
  5.     __In__ const LPWIN_CERTIFICATE winCert,
  6.     __In__ int sizeOfSecurityDirectory,
  7.     __Out__ PolicyInfo* policyInfoForSigner,
  8.     __Out__ LARGE_INTEGER* signingTime,
  9.     __Out__ PolicyInfo* policyInfoForTimestampingAuthority
  10. );
复制代码


(虽然目前逻辑走不到那里问题应该还不大)
ANY.LNK
 楼主| 发表于 4 天前 | 显示全部楼层
DisaPDB 发表于 2025-9-27 12:59
支持,顺便再复述一遍我之前在毒组群里提过的观点:
这根本上就是一场微软对国内某些流氓厂商的黑吃黑
你 ...

或许是微软想偷懒,一个驱动就能自动适配欧盟、北美、亚太地区的不同要求,依据地区自动调整行为

以及,真加密了的话可能又会被某些人指指点点说做贼心虚暗中歧视了
axeaaa
发表于 4 天前 | 显示全部楼层
ANY.LNK 发表于 2025-9-27 15:35
主要是……CiCheckSignedFile的调用方式不对,依据这里的逆向,这个函数是要填8个参数的,其中5个传入,3 ...

确实,你想的完全正确,是我之前看漏了,这里只有一个 BaseAddress 实际传入了…

给这写法整迷糊了,难不成 CiCheckSignedFile 里面哪里还有内置的默认参数?可是看图片函数里面全是在用传入的参数啊……

(头大,既然目前逻辑走不到那里就索性不管了,也不影响后面分析)
truetime
发表于 3 天前 | 显示全部楼层
DisaPDB 发表于 2025-9-27 12:59
支持,顺便再复述一遍我之前在毒组群里提过的观点:
这根本上就是一场微软对国内某些流氓厂商的黑吃黑
你 ...

估计开发者是360过去的。
wowocock
发表于 3 天前 | 显示全部楼层
ANY.LNK 发表于 2025-9-27 15:35
主要是……CiCheckSignedFile的调用方式不对,依据这里的逆向,这个函数是要填8个参数的,其中5个传入,3 ...

函数调用没问题,你看下汇编代码就知道了,那不过是IDA F5的BUG而已,所以还是需要经常切换回汇编代码看,BTW总算是看到一个有点脑子的分析了,而不是有些半吊子在那瞎扯。各种阴谋论。我一直认为无论做人做事还是实事求是的好。
wowocock
发表于 3 天前 | 显示全部楼层
ANY.LNK 发表于 2025-9-27 15:35
主要是……CiCheckSignedFile的调用方式不对,依据这里的逆向,这个函数是要填8个参数的,其中5个传入,3 ...

loc_140013397:                          ; CODE XREF: sub_1400132D4+B7↑j
.text:0000000140013397                 cmp     r9, rcx
.text:000000014001339A                 jb      loc_140013498
.text:00000001400133A0                 mov     eax, [r9]
.text:00000001400133A3                 cmp     eax, 0Ch
.text:00000001400133A6                 jbe     loc_140013498
.text:00000001400133AC                 mov     ecx, 200h
.text:00000001400133B1                 cmp     [r9+4], cx
.text:00000001400133B6                 jnz     loc_140013498
.text:00000001400133BC                 cmp     [r9+6], r11w
.text:00000001400133C1                 jnz     loc_140013498
.text:00000001400133C7                 lea     rcx, [rbp+37h+var_58]
.text:00000001400133CB                 mov     [rsp+0D0h+var_98], rcx
.text:00000001400133D0                 lea     rcx, [rbp+37h+var_60]
.text:00000001400133D4                 mov     [rsp+0D0h+var_A0], rcx
.text:00000001400133D9                 lea     rcx, [rbp+37h+var_90]
.text:00000001400133DD                 mov     [rsp+0D0h+var_A8], rcx
.text:00000001400133E2                 mov     rcx, r10
.text:00000001400133E5                 mov     [rsp+0D0h+var_B0], eax
.text:00000001400133E9                 call    cs:CiCheckSignedFile
自己看下汇编代码里的,rcx,rdx,r8,r9和堆栈里的一些赋值就清楚了。

评分

参与人数 2人气 +6 收起 理由
ANY.LNK + 3 感谢解答: )
axeaaa + 3 感谢解答: )

查看全部评分

您需要登录后才可以回帖 登录 | 快速注册

本版积分规则

手机版|杀毒软件|软件论坛| 卡饭论坛

Copyright © KaFan  KaFan.cn All Rights Reserved.

Powered by Discuz! X3.4( 沪ICP备2020031077号-2 ) GMT+8, 2025-10-1 02:58 , Processed in 0.142784 second(s), 19 queries .

卡饭网所发布的一切软件、样本、工具、文章等仅限用于学习和研究,不得将上述内容用于商业或者其他非法用途,否则产生的一切后果自负,本站信息来自网络,版权争议问题与本站无关,您必须在下载后的24小时之内从您的电脑中彻底删除上述信息,如有问题请通过邮件与我们联系。

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