查看: 569|回复: 0
收起左侧

[技术探讨] 【本人手动渣翻系列·其四】WDAV ELAM(WdBoot.sys)分析

[复制链接]
ANY.LNK
发表于 2024-11-2 20:05:52 | 显示全部楼层 |阅读模式
本帖最后由 ANY.LNK 于 2024-11-2 20:23 编辑

原文地址:https://n4r1b.com/posts/2019/11/ ... dows-defender-elam/

ELAM功能在Windows 8上引入,为特殊签名的驱动提供了一种在其他驱动启动之前启动,并选择是否允许后续驱动加载的方法

*免责声明*

测试于于Windows 10 专业版 1903,WdBoot.sys版本4.18.1910.4

WdBoot初始化

Windows加载过程中组OslpFilterDriverListOnGroup接收字符串Early-Launch,将ELAM驱动由BootDriverListHead复制提前到EarlyLaunchListHead




首先,我们要明白,所有Boot组的驱动程序以及其依赖项均由winload已加载至内存中,但是它们尚需(也是必须)被初始化,此时即是ELAM驱动发挥作用的时候(有几个驱动程序会在ELAM驱动之前初始化,例如CNG.sys使ELAM开发人员可以调用CNG加密基本函数)当然,ELAM也有其自己的初始化阶段,所以让我们进入WdBoot的初始化流程吧。如果我们在DriverEntry设置断点我们可以找到如下的堆栈调用。



WdBoot,以及其他的WD驱动,依赖于WPP跟踪与记录。这意味着其代码中充满了WPP变量和函数,在此我不会描述工作的细节,不过你可以在https://docs.microsoft.com/en-us ... pp-software-tracing获得更多信息

初始化WPP后,代码将尝试删除服务注册表树WdBoot建中的ElamInfo值,这个值基本功能为保存ELAM驱动重选的所有数据(稍后我会讲述这是怎么与何时设置的(该键并不总是设立)。下一步是初始化MpEbGlobals结构,这是WdBoot的主要全局结构。微软提供了这个名称但没有对其的声明与描述,所以我担心这可能涉及暴露信息,但声明类似于如下:

  1. // sizeof(MP_EP_GLOBALS) == 0xB0
  2. typedef struct _MP_EP_GLOBALS
  3. {
  4.   UNICODE_STRING RegistryPath;
  5.   PVOID pHandleRegistration;
  6.   PVOID IoUnregisterBootDriverCallback;
  7.   DWORD Magic; // Set to 0x28EB01
  8.   DWORD SignaturesVersionMajor;
  9.   DWORD SignaturesVersionMinor;
  10.   LIST_ENTRY DriversListEntry;
  11.   PSLIST_ENTRY ElamRegistryEntries;
  12.   PCALLBACK_OBJECT pWdCallbackObject;
  13.   LARGE_INTEGER Cookie;
  14.   _QWORD Unk_Unused1;
  15.   SLIST_HEADER SlistHeader;
  16.   DWORD LoadedDriversCount;
  17.   DWORD LoadedDriversArrayLen;
  18.   PVOID LoadedDriversArray;
  19.   DWORD TotalModulesEntryLen;
  20.   BYTE EntryPointWdFilter[32];
  21.   BYTE FlagWdOrMp;
  22.   BYTE FlagTestMode;
  23.   BYTE FlagPersistElamInfo;
  24.   _QWORD Unk_Unused2;
  25. } MP_EP_GLOBALS, *PMP_EP_GLOBALS;
复制代码


该结构先被设0,后续魔法逻辑(Magic), DriversListEntry, SlistHeader, FlagWdOrMp, FlagTestMode 和 RegistryPath都会被初始化/设置



下一步是创建回调,回调的名称依据FlagWdOrMp而有所不同。该标志决定了驱动必须查找Windows Defender或Microsoft Antimalware Platform。在此我将关注WD的情况,回调使用函数ExCreateCallback创建,名称为\Callback\WdEbNotificationCallback。回调对象将被存储于MP_EP_GLOBALS的对应组件中。

随后函数MpEbInitModuleInformation将被调用,它将初始化包含模块信息的数组,为了完成此目的,将分配一个0x200大小的池(标签Ebib),在初始0x40数组的组件的最后字节将被设置为1(稍后用于检查数组的位置是否已被写入)。最终,变量LoadedDriversArray将被设定为池的地址,变量LoadedDriversArrayLen将被设置为0x800(实际源自LoadedDriversArrayLen & 0x1f | 0x800,但我总是看到它是0x800),使用该值时总是右移5(0x40)

一旦完成上述操作,将通过MmGetSystemRoutineAddress动态获取IoRegisterBootDriverCallback和IoUnregisterBootDriverCallback的地址(若两个函数任意之一不支持,WdBoot将以STATUS_NOT_SUPPORTED结束并退出),此二者启动后,驱动将首先通过MpEbLoadSignatures加载签名。

微软提供了少量的签名存储位置的信息,参见https://learn.microsoft.com/en-g ... #malware-signatures
需要注意的是,ELAM Hive在使用后会被卸载,所以更新时需要先挂载,可在如下位置找到\Windows\System32\config\ELAM

函数很直接,获取ELAM注册表句柄,包含键Windows Defender,在键内部,我们可以找到值Measured(此值会被测量启动记录检测)



值包含签名,因此MpEbLoadSignaturesEx将对其打开一个句柄且MpEbGetSignatures将请求此值以获取数据,同样,其会测取数据的大小。数据和大小都将返回在MpEbLoadSignatures的输出变量中。下一步是从数据中加载签名,负责实现这一功能的函数是EbLoadSignatureData,它很有趣,所以我们将对其进行更深入的分析

EbLoadSignatureData
首先,我必须承认BSI对TPM进行了调查,分析了ELAM的一点情况并取得了对签名加载流程的重大进展,并发布在了https://www.bsi.bund.de/SharedDo ... publicationFile&v=2

此函数包含两个参数,第一个是先前获取的数据,第二个是先前获取的大小。函数首先使用前4字节检查数据是否有效

  1. 1: kd> db rcx L4
  2. ffffab06`9329c00c  ac 00 01 00
复制代码


然后用函数EbAuthenticateSignatureData验证数据是否被篡改

  1. NTSTATUS __fastcall EbAuthenticateSignatureData(
  2.     PUCHAR SignaturesData,
  3.     ULONG SignaturesDataSize,
  4.     _BCRYPT_RSAKEY_BLOB *MpPublicKeyRaw,
  5.     DWORD PublicKeySize,
  6.     PVOID EncryptedSignature,
  7.     DWORD EncryptedSignatureSize
  8. )
复制代码


为获取数据,首先会向Microsoft Primitive Provider请求SHA1算法。这将被用于计算签名数据的哈希,随后请求RSA导入公钥(公钥已嵌入驱动程序,存储于变量g_MpPublicKeyRaw中)用于解密加密的签名并验证是否与先前用BCryptVerifySignature计算出的哈希一致,若已知则会加载签名。为加载签名,驱动按如下方式解析签名数据(伪代码,不包含错误检查和非初始化变量)

  1. ULONG   i = 0;
  2. ULONG   code = 0x80000000;

  3. while(i <= SignaturesDataSize) {
  4.     tag = BYTE(SignaturesData + i);
  5.     EntrySize = BYTE1(SignaturesData + i) |
  6.               (BYTE2(SignaturesData + i) |
  7.               (BYTE3(SignaturesData + i) << 8) << 8);
  8.     switch(tag) {
  9.         case 0xA9:
  10.             SigSize = *(DWORD *)(SignaturesData + i + 4)
  11.             if ( BYTE(SignaturesData + SigSize + i + 8) == 9 ) {
  12.                 AddSignature((SignaturesData + i + 8), SigSize, code);
  13.             }
  14.             break;
  15.         case 0x5C:
  16.             code = *(DWORD *)(SignaturesData + i + 4);
  17.             break;
  18.         case 0x5D:
  19.             code = 0x80000000;
  20.             break;
  21.     }
  22.     i += 4 + EntrySize;
  23. }
复制代码


我希望这能进行有效的说明,下图用不同颜色标记了解析器处理的不同组成部分



正如你所见,签名是16字节的哈希值,在标签字节为0xA9时获得,0x5C的结构为签名名称(例如Trojan:Win64/Necurs.A)

接下来我们转入函数AddSignatures是如何在全局签名组中保存对应的签名。
首先,值会被添加至如下的结构中:

  1. struct SIGNATURE_DATA
  2. {
  3.   DWORD Code;
  4.   BYTE SignatureType;
  5.   BYTE SignatureClassification;
  6.   WORD SigantureSize;
  7.   PVOID pSignature;
  8. };
复制代码


其中SignatureType和SignatureClassification取自以下枚举的某个值

  1. enum SIG_TYPE {
  2.     THUMBPRINT_HASH = 1,
  3.     CERTIFICATE_PUBLISHER = 2,
  4.     ISSUER_NAME = 3,
  5.     IMAGE_HASH = 4,
  6.     REGISTRY = 6,
  7.     VERSION_INFO = 7
  8. }

  9. enum SIG_CLASS {
  10.     KnownGoodImage = 0,
  11.     KnownBadImage = 1,
  12.     KnownBadImageBootCritical = 3,
  13.     UnknownImage = 4
  14. }
复制代码


此函数不会做给保存签名分配池(标签Ebeg)、增加包含签名组大小的全局变量、填充上述的结构外不会做任何事情。签名加载完成后,驱动程序将对签名组进行排序(基于签名类型的MpQuickSort)完成后,遍历组,查找类型为VERSION_INFO或REGISTRY的签名。
在第一种情况下,pSignature指向看起来主要/次要的版本(可能是签名数据库的版本),第二种情况下,pSignature会设置两个标志,后续将检查是否需要注册注册表回调(此次研究中并未观测到此功能的使用,我相信这是个旧功能,因为他们使用CmRegisterCallback注册回调,但此函数在Vista后便已过时)
这就是加载签名的基本方式,完成后驱动程序将注册一个BootDriverCallback(IoRegisterBootDriverCallback)并继续枚举模块。

MpEbEnumerateModules
这是WdBoot初始化阶段最后执行的函数,正如其名,它将列举winload列举的模块并保存此数据以在Boot组驱动回调例程发起时使用。此函数首先调用MpEbGetModuleInformation,执行如下:



此函数的返回将使我们获得一个包含所有加载模块和每个元件大小的组,如下:



获取数据后,函数将遍历数组中的每个条目,并对其执行MpEbAllocateDriverInfoEx,这将初始化我命名为MODULE_ENTRY的结构,我观察到其的声明如下:

  1. // sizeof(MODULE_ENTRY) == 0xB0
  2. struct MODULE_ENTRY
  3. {
  4.   _QWORD Magic;         // Set to 0xB0EB01
  5.   _QWORD WdFilterFlag;  // Set to 0xFBFBFBFBFAFAFAFA
  6.   PVOID SameIndexSlist;
  7.   _QWORD IndexHash;
  8.   LIST_ENTRY DriversListEntry;
  9.   UNICODE_STRING DriverImageName;
  10.   UNICODE_STRING DriverRegistryPath;
  11.   UNICODE_STRING CertPublisher;
  12.   UNICODE_STRING CertIssuer;
  13.   PVOID pImageHashPool;
  14.   DWORD ImageHashAlgorithm;
  15.   DWORD ImageHashLength;
  16.   PVOID pCertThumbprintPool;
  17.   DWORD ThumbprintHashAlgorithm;
  18.   DWORD CertificateThumbprintLength;
  19.   PVOID ImageBase;
  20.   _QWORD ImageSize;
  21.   DWORD ImageFlags;
  22.   DWORD DriverClassification;
  23.   _QWORD ModuleEntryEnd;
  24. };
复制代码


MpEbAllocateDriverInfoEx同样会设置ImageBase、ImageSize、DriverImageName并设置DriverListEntry的Flink和Blink。回到MpEbEnumerateModules,它按如下方式会计算IndexHash的值

  1. WCHAR  upper;
  2. _QWORD IndexHash =  0x4CB2F;
  3. while(*DriverImageName.Buffer) {
  4.       upper = RtlUpcaseUnicodeChar(*DriverImageName.Buffer);
  5.       IndexHash = HIBYTE(upper) + 0x25 * (upper + 0x25 * IndexHash);
  6.       DriverImageName.Buffer++;
  7. }
复制代码


这些值随后被用于计算LoadedDriversArray中的MODULE_ENTRY索引

  1. // Thanks Hex-Rays :)
  2. DWROD size = (LoadedDriversArrayLen >> 5) - 1;
  3. _QWORD tmp = IndexHash & (-1 << (LoadedDriversArrayLen & 0x1F))
  4. _QWORD idx = (0x25 * (BYTE6(tmp) + 0x25 * (BYTE5(tmp) +
  5.          0x25 * (BYTE4(tmp) + 0x25 * (BYTE3(tmp) +
  6.          0x25 * (BYTE2(tmp) + 0x25 * (BYTE1(tmp) +
  7.          0x25 * (BYTE(tmp) + 0xB15DCB))))))) + HIBYTE(tmp)) & size;
复制代码

如果您认识这种算法,请告诉我,我无法找到常量0xB15DCB和0x4CB2F的任何信息

当我们看完BootDriver Callback例程后就明白这些都意义了。现在我们先完结此函数的讲解,由于组的大小可能会短于加载的模块,因此索引值可能会发生冲突。这SameIndexSlist发挥作用的时候。该组件将为索引计算冲突的驱动保存一张单链表(并非SLIST_ENTRY因为最后的入口点不指向NULL)。实际上,保存在LoadedDriversArray的值是SameIndexSlist的指针。
之后函数将检查模块名称是否匹配WdFilter.sys,若是,函数将设置WdFilterFlag并调用MpEbGetEntryPointSnapshot,这是用WdFilter入口点的前32字节填充组件EntryPointWdFilter的函数。
在完成对每个模块的操作后,它将从此函数返回DriverEntry,后者将继续返回STATUS_SUCCESS。

这是初始化的工作,现在我们进入BootDriver Callback流程。

MpEbBootDriverCallback

这可能是WdBoot的主要函数,决定驱动的分类并进一步决定是否应该初始化。此函数预先用IoRegisterBootDriverCallback调用注册。回调的原型由微软在WDK中提供。

  1. void BootDriverCallbackFunction(
  2.   PVOID CallbackContext,
  3.   BDCB_CALLBACK_TYPE Classification,
  4.   PBDCB_IMAGE_INFORMATION ImageInformation
  5. )
复制代码


BDCB_CALLBACK_TYPE 和 _BDCB_IMAGE_INFORMATION均包含在WDK内,我在此就不进行深入讨论了。函数首先检查BDCB_CALLBACK_TYPE是否被设为BdCbStatusUpdate(这基本上是操作系统为Boot组驱动提供的状态更新),若是,则检查驱动是否被分类为BdCbClassificationKnownBadImage。这种情况下它将设置全局变量MpEbGlobals为值ptrSlistEntry,接着通知所有为WdEbNotificationCallback注册的所有回调。若此情况下设置了FlagPersistElamInfo标志,如名所述,它将继续保存MpEbPersistElamInformation收集的数据。在此不作赘述,但大致是将来自每个MODULE_ENTRY的数据打包,然后用SignaturesVersionMajor 和 SignaturesVersionMinor储存在HKLM\SYSTEM\CurrentControlSet\Services\WdBoot键中的ElamInfo值

在分类被设为BdCbInitializeImage的情况下,它将继续尝试并决定BDCB_CLASSIFICATION。为了完成此目的,代码会先将分类设为BdCbClassificationUnknownImage,接着通过MpEbGetModuleEntry尝试获取恰当的MODULE_ENTRY,这也是之前无意义的代码变得有意义的地方。由于在_BDCB_IMAGE_INFORMATION我们拥有了映像名称(ImageName),驱动可以依据名称计算索引哈希并获取实际的索引(当然,必要时可以遍历SameIndexSlist)

我不是优化方面的专家,但很明显这比遍历LoadDriversArray中的所有组件以从每个MOUDLE_ENTRY用DriverImageName对比每个映像名称要快得多
性能是ELAM驱动的关键,微软规定了ELAM必须满足的一些性能要求

若MODULE_ENTRY未找到的情况下,MpEbBootDriverCallback将用函数MpEbAllocateDriverInfoEx2创建入口点并计算IndexHash,并保存新创建的入口进LoadedDriversArray。接着,函数MpEbCopyImageInformation结束对MODULE_ENTRY的填充,函数名称很直观地表明了函数的功能,它从_BDCB_IMAGE_INFORMATION复制出所有的信息至对应的MODULE_ENTRY组件。接着负责决定驱动程序分类的函数EbLookupProperty会被调用,此函数将随后将参数传递给EbLookupPropertyEx

EbLookupPropertyEx
该函数的返回结果将决定驱动程序的分类。这是个递归函数,在被MpEbBootDriverCallback或被递归调用时会有不同的行为(甚至参数类型也会改变)。

如下的反汇编代码更能直观的说明这点:



首先,标签值与如下列表相对应:

  1. enum LOOKUP_PROPERTY {
  2.   CertThumbprintProperty = 1,
  3.   CertPublisherProperty = 2,
  4.   CertIssuerProperty = 3,
  5.   ImageHashProperty = 4,
  6.   EbBootDriverCallback = 5,
  7.   EbRegistryCallback = 6
  8. }
复制代码


从代码中可以看出,这是一个尝试将如下属性与先前加载的签名相匹配的递归调用

·证书颁发者
·证书发布者
·证书指纹
·映像哈希

因此,当此函数源自MpEbBootDriverCallback时,它将通过如下方式调用EbLookupProperty(5, &ModuleEntry->DriversListEntry, 0x90),这将导致首次递归查找的调用。由于该执行Tag不是EbBootDriverCallback且与EbRegistryCallback不同,函数将准备一个SIGNATURE_DATA结构以调用MpBinarySearch。
正如其名,此函数进行二进制搜索,尝试在签名组中找到一个匹配的SIGNATURE_DATA(保存在全局变量中),如果匹配,就返回SIGNATURE_DATA。之后,此递归调用返回签名分类,这就是之前反汇编中崩溃的IF的神奇之处。

  1. SigClass = 0; // KnownGoodImage
  2. ImageHashClass = EbLookupPropertyEx(4,...);
  3. if (ImageHashClass) {
  4.     SigClass = ImageHashClass;
  5. }
  6. return SigClass;
复制代码


于是在结尾,函数将以rax返回SIG_CLASS枚举中的一个值。有了此值后我们返回MpEbBootDriverCallback,在此此值将用以下方法检查:

  1. SigClass = EbLookupProperty(5,...);
  2. if (SigClass)
  3. {
  4.       if (SigClass - 1)
  5.       {
  6.               int tmp = SigClass - 2;
  7.               if (!tmp || tmp != 1)
  8.               {
  9.                     ImageInformation->Classification =
  10.                                               BdCbClassificationUnknown;
  11.                     ModuleEntry->DriverClassification =
  12.                                               BdCbClassificationUnknown;
  13.               }
  14.               else
  15.               {
  16.                    ImageInformation->Classification =
  17.                                               BdCbClassificationKnownBadImageBootCritical;
  18.                    ModuleEntry->DriverClassification =
  19.                                               BdCbClassificationKnownBadImageBootCritical;            
  20.               }
  21.        }
  22.        else
  23.        {
  24.             ImageInformation->Classification =
  25.                               BdCbClassificationKnownBadImage;
  26.             ModuleEntry->DriverClassification =
  27.                               BdCbClassificationKnownBadImage;
  28.        }
  29. }
  30. else
  31. {
  32.     ImageInformation->Classification =
  33.                        BdCbClassificationKnownGoodImage;
  34.     ModuleEntry->DriverClassification =
  35.                        BdCbClassificationKnownGoodImage;
  36. }
复制代码


如果你检查BDCB_CLASSIFICATION,你会注意到返回的值和分类分配的值间存在些许的差异。

·LookupProperty 返回 0 == 指配 BDCB_CLASSIFICATION 1 (已知好映像)
·LookupProperty 返回 1 == 指配 BDCB_CLASSIFICATION 2 (已知坏映像)
·LookupProperty 返回 3 == 指配 BDCB_CLASSIFICATION 3 (关键坏映像)
·LookupProperty 返回 4 == 指配 BDCB_CLASSIFICATION 0 (未知映像)

最后一种情况是,当要初始化的驱动是WdFilter时,回调会再次获取WdFilter的前32字节,并与之前初始化WdBoot时获取的WdFilter字节进行比较。若出现不匹配的情况,入口点将被恢复至原来的情况,组件ModuleEntry->DriverClassification将被设置为6(可能是一个指示一些驱动尝试修改此驱动的分类)最终FlagPersistElamInfo标志将被设立(当然这个会通过WPP记录)



最终,例程将源自MpEbGlobals变量的DriversListEntry同每个MODULE_ENTRY的DriversListEntry链接起来。并将相应值添加至TotalModulesEntryLength,这个值是MODULE_ENTRY中各部分长度的总和。它仅会在ELAM信息被持久化的情况下使用,用于了解必须分配给数据的大小(考虑到信息只保存在BdCbStatusUpdate之上,且我们现在并没有MODULE_ENTRY信息所以代码需要一种方式知晓需要分配多少数据,这也是为何此值被保存为全局变量)



总结

这就是Windows Defender ELAM的工作原理。关于这项技术还有几件事。

首先,其不能提供对抗Bootkit的能力(有其他措施实现此目的,但不是ELAM);
其次,ELAM驱动必须由Early Launch EKU “1.3.6.1.4.1.311.61.4.1”签名,只有微软拥有签署此类证书的能力且只有反恶意软件供应商才有资格使用此证书,因而这不能普遍使用;
最后,默认策略设置在PNP_INITIALIZE_BAD_CRITICAL_DRIVERS中,这意味着未知和关键坏驱动被允许初始化。这可以在如下注册表中配置

HKLM\System\CurrentControlSet\Control\EarlyLaunch\DriverLoadPolicy

最后一件事,微软在https://github.com/Microsoft/Win ... aster/security/elam提供了一份样本ELAM代码示例。它使用的是WDF(即使WdBoot使用的是WDM)

额外补充:WdEbNotificationCallback

在本节中我将解释一些当WdEbNotificationCallback收到通知时会发生的事情,哪个驱动程序会注册该回调,以及它会收到哪些参数。
正如我们之前看到的,此回调在BDCB_CALLBACK_TYPE被设置为BdCbStatusUpdate且_BDCB_IMAGE_INFORMATION分类目标驱动为BdCbClassificationKnownBadImage时在MpEbBootDriverCallback内部被通知。出现这种情况后,ExNotifyCallback函数将被执行

This function notifies every registered routine for the callback object that is specified as the first parameter. Parameter two and three will be passed to the callback routine as Argument1 and Argument2.

如果我们在Systemroot\System32搜索字符串\Callback\WdEbNotificationCallback,我们在WdBoot外同样还会发现一个在WdFilter中的匹配。因此,对照WdFilter上的这个字符串,我们可以发现MpInitializeDriverInfo函数正在为ExRegisterCallback回调对象注册函数MpBootDriverCallback。所以让我们快速浏览一下MpBootDriverCallback



我们可以看到,它们同时检查了Argument1和Argument2,以确保通知来自WdBoot,而非其他尝试冒用此通知的驱动程序



实际上Argument1是一个指向MP_EP_GLOBALS结构中魔法组件的指针。使用此指针,WdFilter就可以很直接简单的使用DriversListEntry遍历加载的驱动程序,并从MODULE_ENTRY结构中获取信息。

实际由MpCopyDriverEntry完成此任务并保存一份加载驱动的本地副本,创立另一个在MpQueryLoadedDrivers函数之后使用的入口列表。在此我们先行搁置,因为此后我将会对WdFilter做一份完成的研究。我们将解释这个,以及更多





PS:这个作者的文字写的是真的多,此外他还有4篇WdFilter的,2篇Smart App Control的,不过我决定先歇一会儿,缓过来之后再翻译剩下的。

本帖子中包含更多资源

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

x

评分

参与人数 1技术 +1 魅力 +2 收起 理由
白露为霜 + 1 + 2 版区有你更精彩: )

查看全部评分

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

本版积分规则

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

Copyright © KaFan  KaFan.cn All Rights Reserved.

Powered by Discuz! X3.4( 沪ICP备2020031077号-2 ) GMT+8, 2024-11-21 17:46 , Processed in 0.121633 second(s), 20 queries .

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

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