羽夏壳世界—— PE 解析的实现
写在前面
此系列是本人一个字一个字码出来的,包括代码实现和效果截图。 如有好的建议,欢迎反馈。码字不易,如果本篇文章有帮助你的,如有闲钱,可以打赏支持我的创作。如想转载,请把我的转载信息附在文章后面,并声明我的个人信息和本人博客地址即可,但必须事先通知我。
你如果是从中间插过来看的,请仔细阅读 羽夏壳世界——序 ,方便学习本教程。
PE 解析实现
既然要做一个加密保护壳,就首先得会解析它。在C++中面向对象的编程的思想体现的比较明显,那么我就建一个类,名为CWingProtect(一看就是老MFC了)。然后头文件文件内容如下:
#pragma once
class CWingProtect
{
};
里面空空如也,啥也没有。为了保证别人用我的东西并且表示是我写的东西,我加了一个命名空间,它就成了这样:
#pragma once
namespace WingProtect
{
class CWingProtect
{
};
}
好,我们需要写一个构造函数,用来解析PE文件。然后调用指定函数来进行保护,那么加上构造函数就这样了:
#pragma once
namespace WingProtect
{
class CWingProtect
{
public:
CWingProtect(const TCHAR* filename, UINT pagecount = 10);
};
}
有构造函数就必然析构函数,加上:
#pragma once
namespace WingProtect
{
class CWingProtect
{
public:
CWingProtect(const TCHAR* filename, UINT pagecount = 10);
~CWingProtect();
};
}
下面我们就开始实现这个构造函数了,思考我们要干什么?
我们首先得解析PE文件,并把相应的处理信息存储起来,放到结构体是再好不过的选择。不过一切的一切前提你得把该文件打开,一种方式是调用文件读写相关的函数,不过这样效率比较低,且不方便。另一种方式直接内存映射的方式,这样读取数据就和在自己读取自己程序的变量一样容易。好,下面我们开始:
CWingProtect::CWingProtect(const TCHAR* filename,UINT pagecount)
{
//初始化分析需要用到的内存
auto alloc = AllocPageSizeMemory();
if (!alloc)
{
_lasterror = ParserError::CannotAllocMemory;
return;
}
memset(alloc, 0, PageSize);
peinfo.AnalysisInfo.ImportDllName = (DllImportName*)alloc;
alloc = AllocReadWriteMem(PageSize * 10);
if (!alloc)
{
_lasterror = ParserError::CannotAllocMemory;
return;
}
memset(alloc, 0, PageSize * 10);
peinfo.AnalysisInfo.ImportFunNameTable = (char*)alloc;
alloc = AllocPageSizeMemory();
if (!alloc)
{
_lasterror = ParserError::CannotAllocMemory;
return;
}
memset(alloc, 0, PageSize);
peinfo.AnalysisInfo.DllFirstThunks = (UINT*)alloc;
//进入分析正题
_lasterror = ParserError::LoadingFile;
if (wcscpy_s(_filename, filename))
{
_lasterror = ParserError::InvalidFileName;
return;
}
_lasterror = ParsePE();
//如果出错的话,清理相关资源
switch (_lasterror)
{
case ParserError::InvalidPE:
UnmapViewOfFile(hmap);
mapping = NULL;
case ParserError::MapViewOfFileError:
if (hmap) CloseHandle(hmap);
case ParserError::FileMappingError:
CloseHandle(hfile);
::memset(&peinfo, 0, sizeof(PEInfo));
break;
case ParserError::OpenFileError:
break;
case ParserError::TooManyImportDlls:
case ParserError::TooManyImportFunctions:
EnableIATEncrypt = FALSE;
break;
default:
break;
}
//解析完毕后创建一个节赋值
peinfo.WingSection = new IMAGE_SECTION_HEADER{};
peinfo.WingSection->VirtualAddress = peinfo.AnalysisInfo.MinAvailableVirtualAddress;
alloc = AllocReadWriteMem(PageSize * pagecount);
if (!alloc)
{
_lasterror = ParserError::CannotAllocMemory;
return;
}
memset(alloc, 0, PageSize * pagecount);
peinfo.WingSecitonBuffer = alloc;
auto filesize = peinfo.FileSize.QuadPart;
alloc = AllocReadWriteMem(filesize);
if (!alloc)
{
_lasterror = ParserError::CannotAllocMemory;
return;
}
packedPE = alloc;
memcpy_s(packedPE, filesize, mapping, filesize);
}
结果上来一看,一堆乱七八糟的代码,什么嘛,猛地一看也没有啥解析PE相关的函数。在解析PE前,我们要做一些初始化操作,比如里面调用分配内存相关的函数。并且你解析的PE不一定是合法的,难免会有错误,最后需要一个返回值表示出错原因(当然最好是加一个异常处理try-catch,当然我懒就没有加,如果以后做这方面的工作,一定要加上)。
其他细节请自行参考我的代码,因为这些代码都已成体系,拆解起来比较麻烦,我们就介绍我们本节比较重要的函数ParsePE。
如下是其代码:
//
// GNU AFFERO GENERAL PUBLIC LICENSE
//Version 3, 19 November 2007
//
//Copyright(C) 2007 Free Software Foundation, Inc.
//Everyone is permitted to copyand distribute verbatim copies
//of this license document, but changing it is not allowed.
// Author : WingSummer (寂静的羽夏)
//
//Warning: You can not use it for any commerical use,except you get
// my AUTHORIZED FORM ME!This project is used for tutorial to teach
// the beginners what is the PE structure and how the packer of the PE files works.
//
// 注意:你不能将该项目用于任何商业用途,除非你获得了我的授权!该项目用来
// 教初学者什么是 PE 结构和 PE 文件加壳程序是如何工作的。
//
ParserError CWingProtect::ParsePE()
{
if (!PathFileExists(_filename))
{
return ParserError::FileNotFound;
}
if (PathIsDirectory(_filename))
{
return ParserError::InvalidFile;
}
hfile = CreateFile(_filename, FILE_READ_ACCESS, FILE_SHARE_WRITE | FILE_SHARE_READ,
NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if (hfile == INVALID_HANDLE_VALUE)
{
return ParserError::OpenFileError;
}
GetFileSizeEx(hfile, &peinfo.FileSize);
hmap = CreateFileMapping(hfile, NULL, PAGE_READONLY, NULL, NULL, NULL);
if (!hmap)
{
return ParserError::FileMappingError;
}
mapping = MapViewOfFile(hmap, FILE_MAP_READ, 0, 0, 0);
if (!mapping)
{
return ParserError::MapViewOfFileError;
}
auto dosHeader = (PIMAGE_DOS_HEADER)mapping;
if (dosHeader->e_magic != IMAGE_DOS_SIGNATURE)
{
return ParserError::InvalidPE;
}
peinfo.ntHeaderOffset = dosHeader->e_lfanew;
auto ntHeader = (PIMAGE_NT_HEADERS)OFFSET(mapping, dosHeader->e_lfanew);
if (ntHeader->Signature != IMAGE_NT_SIGNATURE)
{
return ParserError::InvalidPE;
}
auto bits = *(WORD*)OFFSET(ntHeader, sizeof(DWORD) + IMAGE_SIZEOF_FILE_HEADER);
switch (bits)
{
case IMAGE_NT_OPTIONAL_HDR32_MAGIC:
return Parse32(ntHeader);
case IMAGE_NT_OPTIONAL_HDR64_MAGIC:
return Parse64(ntHeader);
default:
return ParserError::InvalidPE;
}
return ParserError::Success;
}
直到这行代码开始,之前都是进行校验和加载文件到内存的操作:
auto dosHeader = (PIMAGE_DOS_HEADER)mapping;
拿到一个PE文件,首先就要检验它的合法性,比如上来这几行代码:
if (dosHeader->e_magic != IMAGE_DOS_SIGNATURE)
{
return ParserError::InvalidPE;
}
peinfo.ntHeaderOffset = dosHeader->e_lfanew;
auto ntHeader = (PIMAGE_NT_HEADERS)OFFSET(mapping, dosHeader->e_lfanew);
if (ntHeader->Signature != IMAGE_NT_SIGNATURE)
{
return ParserError::InvalidPE;
}
初步校验PE文件合法之后,我们就开始解析文件了,由于PE文件有32位和64位的,我们需要通过一个标识进行获取,如下代码所示:
auto bits = *(WORD*)OFFSET(ntHeader, sizeof(DWORD) + IMAGE_SIZEOF_FILE_HEADER);
switch (bits)
{
case IMAGE_NT_OPTIONAL_HDR32_MAGIC:
return Parse32(ntHeader);
case IMAGE_NT_OPTIONAL_HDR64_MAGIC:
return Parse64(ntHeader);
default:
return ParserError::InvalidPE;
}
这个代码非指针的初学者可能做不出来,这个就是获取IMAGE_OPTIONAL_HEADER的Magic成员,由于IMAGE_FILE_HEADER大小是固定的,再加上Signature成员的大小,就是我们所谓判定位数的成员。如果是32位,就调用Parse32;如果是64位的,就调用Parse64,否则就是非法文件。
Parse32和Parse64代码几乎是一样的,所以我只以Parse64为例开始介绍,如下是该函数的代码:
ParserError CWingProtect::Parse32(PIMAGE_NT_HEADERS ntHeader)
{
is64bit = FALSE;
auto nt = (PIMAGE_NT_HEADERS32)ntHeader;
peinfo.PNumberOfSections = (INT3264)&nt->FileHeader.NumberOfSections;
auto f = nt->FileHeader;
peinfo.NumberOfSections = f.NumberOfSections;
peinfo.PAddressOfEntryPoint = (INT3264)&nt->OptionalHeader.AddressOfEntryPoint;
peinfo.PSizeOfImage = (INT3264)&nt->OptionalHeader.SizeOfImage;
peinfo.PSizeOfHeaders = (INT3264)&nt->OptionalHeader.SizeOfHeaders;
auto o = nt->OptionalHeader;
peinfo.AddressOfEntryPoint = o.AddressOfEntryPoint;
peinfo.Subsystem = o.Subsystem;
if (o.Subsystem == 1)
return ParserError::DriverUnsupport;
peinfo.FileAlignment = o.FileAlignment;
peinfo.SectionAlignment = o.SectionAlignment;
peinfo.ImageBase = o.ImageBase;
peinfo.SizeOfImage = o.SizeOfImage;
peinfo.SizeOfHeaders = o.SizeOfHeaders;
peinfo.POptionalHeaderDllCharacteristics = &nt->OptionalHeader.DllCharacteristics;
return ParserDir(ntHeader);
}
这个函数只是在收集一些必要的PE文件信息,方便我们加密使用,最后调用了比较关键的函数ParserDir,下面是其代码:
//
// GNU AFFERO GENERAL PUBLIC LICENSE
//Version 3, 19 November 2007
//
//Copyright(C) 2007 Free Software Foundation, Inc.
//Everyone is permitted to copyand distribute verbatim copies
//of this license document, but changing it is not allowed.
// Author : WingSummer (寂静的羽夏)
//
//Warning: You can not use it for any commerical use,except you get
// my AUTHORIZED FORM ME!This project is used for tutorial to teach
// the beginners what is the PE structure and how the packer of the PE files works.
//
// 注意:你不能将该项目用于任何商业用途,除非你获得了我的授权!该项目用来
// 教初学者什么是 PE 结构和 PE 文件加壳程序是如何工作的。
//
ParserError CWingProtect::ParserDir(PIMAGE_NT_HEADERS ntHeader)
{
auto s = IMAGE_FIRST_SECTION(ntHeader);
peinfo.PSectionHeaders = s;
auto dd = (INT3264)s - (IMAGE_NUMBEROF_DIRECTORY_ENTRIES * sizeof(IMAGE_DATA_DIRECTORY));
peinfo.PDataDirectory = (PIMAGE_DATA_DIRECTORY)dd;
auto pdtls = peinfo.PDataDirectory[IMAGE_DIRECTORY_ENTRY_TLS];
if (pdtls.VirtualAddress && pdtls.Size)
HasTLS = TRUE;
auto pos = peinfo.AddressOfEntryPoint;
auto pis = encryptInfo.OldImportDataAddr = peinfo.PDataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress;
DWORD maxVirual = 0;
DWORD sizeraw = 0;
for (UINT i = 0; i < peinfo.NumberOfSections; i++)
{
auto psh = s[i];
if (psh.VirtualAddress <= pos && pos <= psh.VirtualAddress + psh.SizeOfRawData)
{
peinfo.PCodeSection = &s[i];
}
if (psh.VirtualAddress <= pis && pis <= psh.VirtualAddress + psh.SizeOfRawData)
{
peinfo.PImportSection = &s[i];
}
if (psh.VirtualAddress > maxVirual)
{
maxVirual = psh.VirtualAddress;
sizeraw = psh.SizeOfRawData;
}
}
auto d = div((INT3264)(maxVirual + sizeraw), peinfo.SectionAlignment);
peinfo.AnalysisInfo.MinAvailableVirtualAddress = (UINT)(GetBiggerQuot(d) * peinfo.SectionAlignment);
peinfo.AnalysisInfo.SectionsCanAddCount = (peinfo.SizeOfHeaders - (UINT)GETOFFSET(mapping, s)) / IMAGE_SIZEOF_SECTION_HEADER - peinfo.NumberOfSections - 1;
auto pdd = (PIMAGE_DATA_DIRECTORY)dd;
auto idd = pdd[IMAGE_DIRECTORY_ENTRY_IMPORT];
auto pidd = (PIMAGE_IMPORT_DESCRIPTOR)GetPointerByRVA(mapping, idd.VirtualAddress);
peinfo.PImportDescriptor = pidd;
IMAGE_IMPORT_DESCRIPTOR iid = pidd[0];
auto pdll = peinfo.AnalysisInfo.ImportDllName;
auto pfunhint = peinfo.AnalysisInfo.ImportFunNameTable;
auto pdiat = peinfo.AnalysisInfo.DllFirstThunks;
if (is64bit)
{
int i = 0, ii = 0, itotal = 0;
if (itotal >= MAXImportFunHintCount)
return ParserError::TooManyImportFunctions;
if (i >= MAXDllNameCount)
return ParserError::TooManyImportDlls;
while (iid.Characteristics)
{
pdll[i].Name = iid.Name;
pdiat[i] = iid.FirstThunk;
PIMAGE_THUNK_DATA64 pitd32 =
(PIMAGE_THUNK_DATA64)GetPointerByRVA(mapping, iid.FirstThunk);
IMAGE_THUNK_DATA64 itd32 = pitd32[0];
PIMAGE_IMPORT_BY_NAME iibn;
ii = 0;
while (itd32.u1.AddressOfData)
{
if (!IMAGE_SNAP_BY_ORDINAL64(itd32.u1.AddressOfData))
{
iibn = (PIMAGE_IMPORT_BY_NAME)GetPointerByRVA(mapping, itd32.u1.AddressOfData);
auto c = (char*)&iibn->Name;
auto le = strlen(c);
strcpy_s(pfunhint, PageSize, c); //偷懒写法
pfunhint += (le + 1);
}
ii++;
itd32 = pitd32[ii];
}
pdll[i].FunCount = ii;
itotal += ii;
i++;
iid = pidd[i];
}
peinfo.AnalysisInfo.ImportDllCount = i;
peinfo.AnalysisInfo.ImportFunCount = itotal;
peinfo.AnalysisInfo.PointerofImportFunNameTable =
(UINT)GETOFFSET(peinfo.AnalysisInfo.ImportFunNameTable, pfunhint);
}
else
{
int i = 0, ii = 0, itotal = 0;
while (iid.Characteristics)
{
pdll[i].Name = iid.Name;
pdiat[i] = iid.FirstThunk;
PIMAGE_THUNK_DATA32 pitd32 =
(PIMAGE_THUNK_DATA32)GetPointerByRVA(mapping, iid.FirstThunk);
IMAGE_THUNK_DATA32 itd32 = pitd32[0];
PIMAGE_IMPORT_BY_NAME iibn;
ii = 0;
while (itd32.u1.AddressOfData)
{
if (!IMAGE_SNAP_BY_ORDINAL64(itd32.u1.AddressOfData))
{
iibn = (PIMAGE_IMPORT_BY_NAME)GetPointerByRVA(mapping, itd32.u1.AddressOfData);
auto c = (char*)&iibn->Name;
auto le = strlen(c);
strcpy_s(pfunhint, PageSize, c); //偷懒写法
pfunhint += (le + 1);
}
ii++;
itd32 = pitd32[ii];
}
pdll[i].FunCount = ii;
itotal += ii;
i++;
iid = pidd[i];
}
peinfo.AnalysisInfo.ImportDllCount = i;
peinfo.AnalysisInfo.ImportFunCount = itotal;
peinfo.AnalysisInfo.PointerofImportFunNameTable =
(UINT)GETOFFSET(peinfo.AnalysisInfo.ImportFunNameTable, pfunhint);
}
return ParserError::Success;
}
上面的代码对执行的代码区进行确认和定位,记录做后续的加密,于此同时我们记录了必要的数据,接下来就是解析导入表,我们以64位为例进行介绍:
int i = 0, ii = 0, itotal = 0;
while (iid.Characteristics)
{
pdll[i].Name = iid.Name;
pdiat[i] = iid.FirstThunk;
PIMAGE_THUNK_DATA32 pitd32 =
(PIMAGE_THUNK_DATA32)GetPointerByRVA(mapping, iid.FirstThunk);
IMAGE_THUNK_DATA32 itd32 = pitd32[0];
PIMAGE_IMPORT_BY_NAME iibn;
ii = 0;
while (itd32.u1.AddressOfData)
{
if (!IMAGE_SNAP_BY_ORDINAL64(itd32.u1.AddressOfData))
{
iibn = (PIMAGE_IMPORT_BY_NAME)GetPointerByRVA(mapping, itd32.u1.AddressOfData);
auto c = (char*)&iibn->Name;
auto le = strlen(c);
strcpy_s(pfunhint, PageSize, c); //偷懒写法
pfunhint += (le + 1);
}
ii++;
itd32 = pitd32[ii];
}
pdll[i].FunCount = ii;
itotal += ii;
i++;
iid = pidd[i];
}
peinfo.AnalysisInfo.ImportDllCount = i;
peinfo.AnalysisInfo.ImportFunCount = itotal;
peinfo.AnalysisInfo.PointerofImportFunNameTable =
(UINT)GETOFFSET(peinfo.AnalysisInfo.ImportFunNameTable, pfunhint);
while (iid.Characteristics)和while (itd32.u1.AddressOfData)这个几个代码就是判断是否为空,为空就中断循环。pdll是导入名称不定长字符串数组,pfunhint是导入函数名称不定长字符串数组,pdiat是每一个IMAGE_IMPORT_DESCRIPTOR中的FirstThunk拷贝来作为的数组,而这些都是我进行IAT加密所需要的数据,这到后面的文章进行介绍,这里就不多说了。
经历过一系列的解析,如果成功,就会执行return ParserError::Success;表示解析成功,构造函数继续执行后续加密所需资源的申请和处理,这些并不是本篇博文的关注点,就不赘述了。
下一篇
羽夏壳世界—— PE 解析的实现的更多相关文章
- 羽夏壳世界—— PE 结构(上)
羽夏壳世界之 PE 结构(上),介绍难度较低的基本 PE 相关结构体.
- 跟羽夏学 Ghidra ——窗口
写在前面 此系列是本人一个字一个字码出来的,包括示例和实验截图.本人非计算机专业,可能对本教程涉及的事物没有了解的足够深入,如有错误,欢迎批评指正. 如有好的建议,欢迎反馈.码字不易,如果本篇文章 ...
- (三)羽夏看C语言——进制
写在前面 由于此系列是本人一个字一个字码出来的,包括示例和实验截图.本人非计算机专业,可能对本教程涉及的事物没有了解的足够深入,如有错误,欢迎批评指正. 如有好的建议,欢迎反馈.码字不易,如果本篇 ...
- 羽夏逆向指引—— Hook
写在前面 此系列是本人一个字一个字码出来的,包括示例和实验截图.可能有错误或者不全面的地方,如有错误,欢迎批评指正,本教程将会长期更新. 如有好的建议,欢迎反馈.码字不易,如果本篇文章有帮助你的, ...
- 羽夏看Linux内核——段相关入门知识
写在前面 此系列是本人一个字一个字码出来的,包括示例和实验截图.如有好的建议,欢迎反馈.码字不易,如果本篇文章有帮助你的,如有闲钱,可以打赏支持我的创作.如想转载,请把我的转载信息附在文章后面,并 ...
- 羽夏看Linux内核——中断与分页相关入门知识
写在前面 此系列是本人一个字一个字码出来的,包括示例和实验截图.如有好的建议,欢迎反馈.码字不易,如果本篇文章有帮助你的,如有闲钱,可以打赏支持我的创作.如想转载,请把我的转载信息附在文章后面,并 ...
- 羽夏看Linux内核——引导启动(上)
写在前面 此系列是本人一个字一个字码出来的,包括示例和实验截图.如有好的建议,欢迎反馈.码字不易,如果本篇文章有帮助你的,如有闲钱,可以打赏支持我的创作.如想转载,请把我的转载信息附在文章后面,并 ...
- 羽夏看Linux内核——引导启动(下)
写在前面 此系列是本人一个字一个字码出来的,包括示例和实验截图.如有好的建议,欢迎反馈.码字不易,如果本篇文章有帮助你的,如有闲钱,可以打赏支持我的创作.如想转载,请把我的转载信息附在文章后面,并 ...
- 跟羽夏学 Ghidra ——工具
写在前面 此系列是本人一个字一个字码出来的,包括示例和实验截图.本人非计算机专业,可能对本教程涉及的事物没有了解的足够深入,如有错误,欢迎批评指正. 如有好的建议,欢迎反馈.码字不易,如果本篇文章 ...
- PE解析器的编写(四)——数据目录表的解析
在PE结构中最重要的就是区块表和数据目录表,上节已经说明了如何解析区块表,下面就是数据目录表,在数据目录表中一般只关心导入表,导出表和资源这几个部分,但是资源实在是太复杂了,而且在一般的病毒木马中也不 ...
随机推荐
- java通过jsch使用sftp连接linux处理文件
1.Maven依赖 <!--Java连接Linux服务器依赖--> <dependency> <groupId>com.jcraft</groupId> ...
- 京东一面挂在了CAS算法的三大问题上,痛定思痛不做同一个知识点的小丑
写在开头 在介绍synchronized关键字时,我们提到了锁升级时所用到的CAS算法,那么今天我们就来好好学一学这个CAS算法. CAS算法对build哥来说,可谓是刻骨铭心,记得是研二去找实习的时 ...
- #裴蜀定理#CF7C Line
题目 给定三个整数\(a,b,c\),问是否能找到两个数\(x,y\)使得\(ax+by+c=0\),没有则输出-1 分析 先把式子转换成\(ax+by=-c\) 然后\(x,y\)是整数当且仅当\( ...
- 使用OHOS SDK构建tinyexr
参照OHOS IDE和SDK的安装方法配置好开发环境. 从github下载源码. 执行如下命令: git clone https://github.com/syoyo/tinyexr.git 进入源码 ...
- centos环境minio安装踩坑指南2023年7月30日
MinIO的安装踩坑指南 环境centos7 1. 安装MinIO官方文档 Binary下载 , 按照官网的路径配置比较快 下载minio wget https://dl.min.io/server/ ...
- ssm 创建bean的三种方式和spring依赖注入的三种方式
<!--创建bean的第一种方式:使用默认无参构造函数 在默认情况下: 它会根据默认无参构造函数来创建类对象.如果 bean 中没有默认无参构造函数,将会创建失败--> <bean ...
- redis 简单整理——Lua[十一]
前言 简单介绍一下Lua. 正文 为了保证多条命令组合的原子性,Redis提供了简单的事务功能以及集 成Lua脚本来解决这个问题. 前面提及到pipline,也提及到pipline 并不是原子性的,如 ...
- c# Mutex 互斥锁
前言 互斥锁(Mutex) 互斥锁是一个互斥的同步对象,意味着同一时间有且仅有一个线程可以获取它. 互斥锁可适用于一个共享资源每次只能被一个线程访问的情况. 正文 代码: static void Ma ...
- 国产gowin开发板GW1NR-9K的PSRAM使用说明
开发板子采用GW1NNR-LV9LQ144PC6/I5 FPGA器件.具有低功耗,瞬时启动,高安全性,低成本,方便扩展等特点.本开发板价格价格便宜,板子扩张性容易,帮助用户比较快速进入国产FPGA学习 ...
- 力扣385(java)-迷你语法分析器(中等)
题目: 给定一个字符串 s 表示一个整数嵌套列表,实现一个解析它的语法分析器并返回解析的结果 NestedInteger . 列表中的每个元素只可能是整数或整数嵌套列表 示例 1: 输入:s = &q ...