句柄
进程的地址空间分为系统空间和用户空间,内核对象都保存在系统空间中,用户空间不能通过地址作为指针来引用它们,Windows使用句柄(handle)来对内核对象进行引用。看起来很小,但是涉及的内容很多
所谓的句柄值其实是进程结构体中句柄表中的索引,通过该句柄值在句柄表中进行逻辑换算就可以变成内核对象的指针来进行操作。
因为是进程句柄表中的索引,所以句柄只在进程中有效。一个进程中的句柄值传递给另外一个进程后,句柄值将不再有效。这种表被称为私有句柄表或者进程句柄表。
在Windows中还有一种句柄表叫全局句柄表是Windows全局都可以使用的,和私有句柄表稍许区别。
在EPROCESS结构体中,对应的ObjectTable字段来包含进程的句柄表信息。
注:OS环境为Win7 SP1 32位
句柄表是一个多层结构,最多有三层,最少有一层。层数由TableCode的低2位的值来判断,低2位为0时为1层,为1时为2层,为2时为3层。
例如:TableCode = 0x88884222; 其中的低二位为10B,也就是0x2,所以该句柄表就为三层结构。
最底层结构中保存的内容才是实实在在需要的内容,叫做句柄项(_HANDLE_TABLE_ENTRY),往上的层数内容都是在当前层数的句柄表不够时新建的数组来保存之前的句柄表的首地址。类似于分页管理中的机制,通过好几个数组来嵌套,最终有效的,实实在在指向了页的起始地址的只有页表项。
注,以WRK中的代码为例:
在执行体中创建进程时会首先为新进程分配一个单层句柄表,然后并初始化。
句柄在句柄表中呈线性增加,当增加一个句柄时会在当前最后一个句柄表的数组中往后添加一个句柄表项。
接着随着进程中句柄数的增加,如果当前句柄表不够使用,就会扩展句柄表,以此由单层到2层最多到3层。Windows中进程的句柄是有限制的在WRK中最多有2的24次方个。类似于分页管理机制。
创建单层句柄表采用:ExCreateHandleTable()函数来完成,初始化句柄表采用ExpAllocateHandleTable()函数来完成,扩展句柄表采用ExpAllocateHandleTableEntrySlow()函数来完成。
以上函数在WRK中都有仔细记载。
TableCode字段是句柄表信息中最关键的字段,前面写道该字段的低2位标识了句柄表的层数,除了该作用还有别的作用。
该字段的低两位清零后的地址为句柄表的最高层表的首地址。
一个有效的句柄有四种可能:
-1和-2的情况不需要详解,主要是句柄值为一个正常的4倍数的情况。
同时还要区分私有句柄表和全局句柄表,两者的内容有一点小小的区别。
(参考WRK)
私有句柄表和公有句柄表唯一的区别是句柄表项的不同,私有表的_HANDLE_TABLE_ENTRY内存放的是指向OBJECT_HEADETR对象头的首地址,而公有表中存放的是对象Body的首地址。
首先在虚拟机中采用Process Explorer来查看notepad++中的句柄内容:
然后根据句柄值来找到对应的内核对象的地址:
1 在Windbg中找到notepad++的句柄表_HANDLE_TABLE 结构体首地址:
2 通过_HANDLE_TABLE结构体得到TableCode内容:
3 解析TableCode:
4 根据句柄表的层数和句柄值来得到句柄所在的最底层句柄表。
5 通过句柄得到句柄项在表中的偏移:
6 查看值:
和前面Desktop内核对象对应的值一样。
公有句柄表和私有句柄表的区别很小,首先公有句柄表有一个全局变量PspCidTable来保存起始地址。
这里需要纠正一下Windows内核原理与实现一书中的内容:
在Win7中全局句柄表和system进程句柄表内容不同:
但是system进程的句柄表又有个全局变量ObpKernelHandleTable来表示,看着名字这个system进程的句柄表应该叫内核句柄表吧,猜测是给内核驱动使用的句柄表。
1 地址的区别: 公有句柄表由PspCidTable全局变量来保存,私有地址表在进程中保存。
2 句柄表项的区别: 公有句柄表项中的对象地址是对象body的首地址,而私有地址表的对象地址是对象的头的首地址
和前面差不多,只是在查看内核对象时稍有区别:
句柄是拥有操作权限的,不然胡乱使用很恐怖的,稍微懂点就可以让你的Windows系统坏掉。
(注:每个内核对象的句柄权限是有区别的,类似于对象头和对象的关系,这里以进程对象句柄举例。)
句柄的权限就保存在刚刚的句柄表项中:
将该结构体划分为几个板块:
在有一些博客上是这样讲的:
这个结论有对也有错。
根据上述博客提供的结果再加上我的验证得出的正确结论如下:
64位分为:
首先介绍这24位的原因是,它是通过OpenProcess函数来指定访问掩码的,这个函数用过比较熟练。
其中访问掩码的Microsoft官方文档:
Process Security and Access Rights - Win32 apps | Microsoft Docs
在官方文档中介绍了通用的访问掩码:
和针对进程的访问掩码:
其中将不通用访问掩码加起来可以得到 0xFFFF,然后将通用的访问掩码加起来得到 0x1F。
然后我查看了官方文档阐述了如果采用PROCESS_ALL_ACCESS得到的句柄的前32位值的大小为:0x001FFFFF
采用代码验证:
将结果放到od里然后定位到if (tempHandle == NULL)语句中,因为这里可以直接看到handle值:
然后运行到这里,并输入进程的PID,这里我选择输入任务管理器中的notepad++的PID:
然后查看eax的值得到句柄值:
handle == ==eax ==== 0x24
然后用Windbg段下来后通过前面讲述的查看私有句柄表的方式得到该句柄对应的句柄表项:
可以看到对应的句柄表项为:
001fffff`88175969
所以32-55bit的值为1FFFFF和微软的文档是吻合的。(不要怀疑微软的文档)。
这里的结果还要分为高四位和低四位,高四位的值始终为0,而低四位的值会因为SetHandleInformation()函数设置的HANDLE_FLAG_PROTECT_FROM_CLOSE标志位而改变。
SetHandleInformation()函数官方文档 :
SetHandleInformation function (handleapi.h) - Win32 apps | Microsoft Docs
我的验证代码如下:
这一次我在打开句柄时只赋值了一个0x0001的访问掩码,为了方便观察。
通用采用了前面的办法,然后停在了SetHandleInformation(tempHandle, HANDLE_FLAG_PROTECT_FROM_CLOSE, HANDLE_FLAG_PROTECT_FROM_CLOSE);函数这里:
然后查看调用前的句柄表项值:
接着查看调用后的:
就从00000001变到了02000001,也就是56-64中的低4位从0变成了2。
这个前面实验过很多次了,这里就不实验了。
在WRK中它的值如下:
#define OBJ_HANDLE_ATTRIBUTES (OBJ_PROTECT_CLOSE | OBJ_INHERIT | OBJ_AUDIT_OBJECT_CLOSE)
0bit位表示OBJ_AUDIT_OBJECT_CLOSE,
1bit位表示OBJ_INHERIT ,
2bit位表示OBJ_PROTECT_CLOSE 。
OBJ_AUDIT_OBJECT_CLOSE已经被取缔为表示句柄表项的锁标志了,如果为1表示句柄表项被锁住了。(这个实验我暂时弄不出来)
OBJ_INHERIT :表示是否可以被该进程创建的子进程继承。
OBJ_PROTECT_CLOSE 指示关闭该对象时是否产生一个审计事件。(这个我也弄不出来)
OBJ_INHERIT可以在三个地方修改:
1:在打开句柄时函数的OpenProcess(dwDesiredAccess,BInheritHandle,dwProcessId),中第二个参数的指定。
2:采用SetHandleInformation(tempHandle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT)来增加权限。
3:直接修改句柄表项的内容。
前面介绍了私有句柄表,但是没有完整的解释私有句柄表的内容。
首先私有句柄表只包含进程和线程。进程有一个唯一ID,在EPROCESS中叫做UniqueProcessId,ETHREAD有一个_CLIENT_ID字段包含了线程的唯一ID和线程对应的进程ID。
这里的唯一ID是在创建进程和线程时通过ExCreateHandle函数在全局句柄表PspClidTable中创建的句柄索引值,此表也被叫做CID句柄表(Client ID handle table)。在WRK中可以找到有效证据:
所以PID和TID其实也是句柄值,只不过是对应的全局句柄表。所以进程的PID和线程的TID也是和进程一样都是4的倍数,至于0值,在windows中0值是给空闲进程留着的。
然后全局句柄表的首地址保存在全局变量PspClidTable中,至于原因得问Microsoft了。
最后是全局句柄表中的句柄表项对应对象地址的是内核对象Body地址,而不是私有句柄表中的内核对象的对象头地址。
由于保存的是body地址,所以在内核中,根据进程或线程的唯一ID值,可以很快的找到对应的内核对象,例如以下API:
别的就和私有句柄表没差了。
句柄是在应用层的一种内核对象的使用方式通常是和API一起使用,在内核中也可以使用,句柄要通过句柄表来和内核对象进行关联,句柄表又分为私有句柄表和公有句柄表。
当通过API+句柄的形式来使用内核对象时,操作系统通过句柄值来访问句柄表得到对应的句柄表项的内容,然后根据句柄表项的内容验证句柄的访问权限,后再进行API对应的操作来操作内核对象。
《Windows内核原理与实现》 潘爱民
微软官方文档
借鉴博客:
私有句柄表(内核对象,并非用户对象),全局句柄表_寻梦&之璐的博客-CSDN博客
/
/
0x3c
bytes (sizeof)
struct _HANDLE_TABLE
{
ULONG TableCode;
/
/
0x0
/
/
指向句柄表的存储结构(非常重要的字段)
struct _EPROCESS
*
QuotaProcess;
/
/
0x4
/
/
句柄表的内存资源存储在此进程中
VOID
*
UniqueProcessId;
/
/
0x8
/
/
创建进程的
ID
,用于回调函数
struct _EX_PUSH_LOCK HandleLock;
/
/
0xc
/
/
句柄表锁,仅在句柄表扩展时使用
struct _LIST_ENTRY HandleTableList;
/
/
0x10
/
/
所有的句柄表形成一个链表,该字段作为一个链表节点
/
/
链表头为全局变量HandleTableListHead
struct _EX_PUSH_LOCK HandleContentionEvent;
/
/
0x18
/
/
访问句柄时发生竞争,就通过该推锁进行等待
struct _HANDLE_TRACE_DEBUG_INFO
*
DebugInfo;
/
/
0x1c
/
/
仅当使用调试句柄时才有意义
LONG
ExtraInfoPages;
/
/
0x20
/
/
审计信息所占用的页面数量
union
{
ULONG Flags;
/
/
0x24
/
/
标志域
UCHAR StrictFIFO:
1
;
/
/
0x24
/
/
是否使用队列的风格,FIFO先进先出,先释放的地方先使用。
};
ULONG FirstFreeHandle;
/
/
0x28
/
/
当前句柄表中的空闲句柄表项的索引值
/
/
句柄索引值按HANDLE_VALUE_INC逐个递增,在win7 sp1
32
位中为
4
字节
struct _HANDLE_TABLE_ENTRY
*
LastFreeHandleEntry;
/
/
0x2c
/
/
当前句柄表中最后一个空闲句柄表项的地址
ULONG HandleCount;
/
/
0x30
/
/
正在使用的句柄表项数量
ULONG NextHandleNeedingPool;
/
/
0x34
/
/
下一次句柄表扩展的起始句柄索引,也就是下一个新的句柄表的首地址
ULONG HandleCountHighWatermark;
/
/
0x38
};
/
/
0x8
bytes (sizeof)
struct _HANDLE_TABLE_ENTRY
{
union
{
VOID
*
Object
;
/
/
0x0
ULONG ObAttributes;
/
/
0x0
struct _HANDLE_TABLE_ENTRY_INFO
*
InfoTable;
/
/
0x0
ULONG Value;
/
/
0x0
};
union
{
ULONG GrantedAccess;
/
/
0x4
struct
{
USHORT GrantedAccessIndex;
/
/
0x4
USHORT CreatorBackTraceIndex;
/
/
0x6
};
ULONG NextFreeTableEntry;
/
/
0x4
};
};
/
*
该结构体后续再解释 目前只需知道该结构体的低
32
位到低
2
位保存的是内核对象的首地址
以下在结构体中的低地址的union的
32
-
2
位中保存着首地址
union
{
VOID
*
Object
;
/
/
0x0
ULONG ObAttributes;
/
/
0x0
struct _HANDLE_TABLE_ENTRY_INFO
*
InfoTable;
/
/
0x0
ULONG Value;
/
/
0x0
};
例如:_handle_table_entry
=
=
00000001
`
8812ad09
那么对应的对象首地址就为
8812ad09
将低
3
位清零的结果:
8812ad08
*
/
/
/
0x3c
bytes (sizeof)
struct _HANDLE_TABLE
{
ULONG TableCode;
/
/
0x0
/
/
指向句柄表的存储结构(非常重要的字段)
struct _EPROCESS
*
QuotaProcess;
/
/
0x4
/
/
句柄表的内存资源存储在此进程中
VOID
*
UniqueProcessId;
/
/
0x8
/
/
创建进程的
ID
,用于回调函数
struct _EX_PUSH_LOCK HandleLock;
/
/
0xc
/
/
句柄表锁,仅在句柄表扩展时使用
struct _LIST_ENTRY HandleTableList;
/
/
0x10
/
/
所有的句柄表形成一个链表,该字段作为一个链表节点
/
/
链表头为全局变量HandleTableListHead
struct _EX_PUSH_LOCK HandleContentionEvent;
/
/
0x18
/
/
访问句柄时发生竞争,就通过该推锁进行等待
struct _HANDLE_TRACE_DEBUG_INFO
*
DebugInfo;
/
/
0x1c
/
/
仅当使用调试句柄时才有意义
LONG
ExtraInfoPages;
/
/
0x20
/
/
审计信息所占用的页面数量
union
{
ULONG Flags;
/
/
0x24
/
/
标志域
UCHAR StrictFIFO:
1
;
/
/
0x24
/
/
是否使用队列的风格,FIFO先进先出,先释放的地方先使用。
};
ULONG FirstFreeHandle;
/
/
0x28
/
/
当前句柄表中的空闲句柄表项的索引值
/
/
句柄索引值按HANDLE_VALUE_INC逐个递增,在win7 sp1
32
位中为
4
字节
struct _HANDLE_TABLE_ENTRY
*
LastFreeHandleEntry;
/
/
0x2c
/
/
当前句柄表中最后一个空闲句柄表项的地址
ULONG HandleCount;
/
/
0x30
/
/
正在使用的句柄表项数量
ULONG NextHandleNeedingPool;
/
/
0x34
/
/
下一次句柄表扩展的起始句柄索引,也就是下一个新的句柄表的首地址
ULONG HandleCountHighWatermark;
/
/
0x38
};
/
/
0x8
bytes (sizeof)
struct _HANDLE_TABLE_ENTRY
{
union
{
VOID
*
Object
;
/
/
0x0
ULONG ObAttributes;
/
/
0x0
struct _HANDLE_TABLE_ENTRY_INFO
*
InfoTable;
/
/
0x0
ULONG Value;
/
/
0x0
};
union
{
ULONG GrantedAccess;
/
/
0x4
struct
{
USHORT GrantedAccessIndex;
/
/
0x4
USHORT CreatorBackTraceIndex;
/
/
0x6
};
ULONG NextFreeTableEntry;
/
/
0x4
};
};
/
*
该结构体后续再解释 目前只需知道该结构体的低
32
位到低
2
位保存的是内核对象的首地址
以下在结构体中的低地址的union的
32
-
2
位中保存着首地址
union
{
VOID
*
Object
;
/
/
0x0
ULONG ObAttributes;
/
/
0x0
struct _HANDLE_TABLE_ENTRY_INFO
*
InfoTable;
/
/
0x0
ULONG Value;
/
/
0x0
};
例如:_handle_table_entry
=
=
00000001
`
8812ad09
那么对应的对象首地址就为
8812ad09
将低
3
位清零的结果:
8812ad08
*
/
句柄值 |
含义 |
-1 |
代表当前进程 |
-2 |
代表当前线程 |
一个为4倍数的正数 |
句柄表中的索引 |
负数 |
其值的绝对值为System进程中的句柄值 |
kd> !process
0
0
PROCESS
86e97d20
SessionId:
1
Cid:
03e8
Peb:
7ffdf000
ParentCid:
0594
DirBase:
07f70000
ObjectTable: a79b91c0 HandleCount:
72.
Image: notepad
+
+
.exe
/
/
首地址为:a79b91c0
kd> !process
0
0
PROCESS
86e97d20
SessionId:
1
Cid:
03e8
Peb:
7ffdf000
ParentCid:
0594
DirBase:
07f70000
ObjectTable: a79b91c0 HandleCount:
72.
Image: notepad
+
+
.exe
/
/
首地址为:a79b91c0
kd> dt _HANDLE_TABLE a79b91c0
ntdll!_HANDLE_TABLE
+
0x000
TableCode :
0x8b4a0000
+
0x004
QuotaProcess :
0x86e97d20
_EPROCESS
+
0x008
UniqueProcessId :
0x000003e8
Void
+
0x00c
HandleLock : _EX_PUSH_LOCK
+
0x010
HandleTableList : _LIST_ENTRY [
0x84145e28
-
0xa87854f8
]
+
0x018
HandleContentionEvent : _EX_PUSH_LOCK
+
0x01c
DebugInfo : (null)
+
0x020
ExtraInfoPages :
0n0
+
0x024
Flags :
0
+
0x024
StrictFIFO :
0y0
+
0x028
FirstFreeHandle :
0xcc
+
0x02c
LastFreeHandleEntry :
0x8b4a0ff8
_HANDLE_TABLE_ENTRY
+
0x030
HandleCount :
0x48
+
0x034
NextHandleNeedingPool :
0x800
+
0x038
HandleCountHighWatermark :
0x4b
kd> dt _HANDLE_TABLE a79b91c0
ntdll!_HANDLE_TABLE
+
0x000
TableCode :
0x8b4a0000
+
0x004
QuotaProcess :
0x86e97d20
_EPROCESS
+
0x008
UniqueProcessId :
0x000003e8
Void
+
0x00c
HandleLock : _EX_PUSH_LOCK
+
0x010
HandleTableList : _LIST_ENTRY [
0x84145e28
-
0xa87854f8
]
+
0x018
HandleContentionEvent : _EX_PUSH_LOCK
+
0x01c
DebugInfo : (null)
+
0x020
ExtraInfoPages :
0n0
+
0x024
Flags :
0
+
0x024
StrictFIFO :
0y0
+
0x028
FirstFreeHandle :
0xcc
+
0x02c
LastFreeHandleEntry :
0x8b4a0ff8
_HANDLE_TABLE_ENTRY
+
0x030
HandleCount :
0x48
+
0x034
NextHandleNeedingPool :
0x800
+
0x038
HandleCountHighWatermark :
0x4b
0x8b4a0000
的前四位为:
0000
表明只有一层句柄表,
清零后得到句柄表的首地址:
0x8b4a0000
0x8b4a0000
的前四位为:
0000
表明只有一层句柄表,
清零后得到句柄表的首地址:
0x8b4a0000
4.1
如果只有一层就是TableCode前
30
位的值
4.2
如果有两层就需要先将句柄值除以
512
,看看占满了多少个最底层句柄表,然后将在TableCode中找到前面占满了的最底层句柄表的首地址的存放地址,再后面一个就是对应的最底层句柄表了。然后将句柄值
-
占满句柄表的个数
*
512
等到在对应的最底层句柄表中句柄的偏移值,然后将该值
*
2
得到句柄表项在句柄表中的偏移值。
4.3
和
4.2
类似。
这里的情况就是
4.1
,直接可以得到对应表的地址为
0x8b4a0000
4.1
如果只有一层就是TableCode前
30
位的值
4.2
如果有两层就需要先将句柄值除以
512
,看看占满了多少个最底层句柄表,然后将在TableCode中找到前面占满了的最底层句柄表的首地址的存放地址,再后面一个就是对应的最底层句柄表了。然后将句柄值
-
占满句柄表的个数
*
512
等到在对应的最底层句柄表中句柄的偏移值,然后将该值
*
2
得到句柄表项在句柄表中的偏移值。
4.3
和
4.2
类似。
这里的情况就是
4.1
,直接可以得到对应表的地址为
0x8b4a0000
句柄表中存放的是句柄项,句柄项是一个结构体里面包含了句柄值:
/
/
0x8
bytes (sizeof)
struct _HANDLE_TABLE_ENTRY
{
union
{
VOID
*
Object
;
/
/
0x0
/
/
指向句柄代表的对象
ULONG ObAttributes;
/
/
0x0
struct _HANDLE_TABLE_ENTRY_INFO
*
InfoTable;
/
/
0x0
ULONG Value;
/
/
0x0
};
union
{
ULONG GrantedAccess;
/
/
0x4
struct
{
USHORT GrantedAccessIndex;
/
/
0x4
USHORT CreatorBackTraceIndex;
/
/
0x6
};
ULONG NextFreeTableEntry;
/
/
0x4
};
};
因为句柄表项结构大小为
8
字节,而句柄的大小为
4
字节,所以在得到句柄表中句柄表项的偏移时,还需要将对应句柄表的句柄值
*
2
。
例如这里的句柄表值为
0x28
,那么对应到句柄表中的偏移为
0x28
*
2
句柄表中存放的是句柄项,句柄项是一个结构体里面包含了句柄值:
/
/
0x8
bytes (sizeof)
struct _HANDLE_TABLE_ENTRY
{
union
{
VOID
*
Object
;
/
/
0x0
/
/
指向句柄代表的对象
ULONG ObAttributes;
/
/
0x0
struct _HANDLE_TABLE_ENTRY_INFO
*
InfoTable;
/
/
0x0
ULONG Value;
/
/
0x0
};
union
{
ULONG GrantedAccess;
/
/
0x4
struct
{
USHORT GrantedAccessIndex;
/
/
0x4
USHORT CreatorBackTraceIndex;
/
/
0x6
};
ULONG NextFreeTableEntry;
/
/
0x4
};
};
因为句柄表项结构大小为
8
字节,而句柄的大小为
4
字节,所以在得到句柄表中句柄表项的偏移时,还需要将对应句柄表的句柄值
*
2
。
例如这里的句柄表值为
0x28
,那么对应到句柄表中的偏移为
0x28
*
2
/
/
通过句柄表
+
偏移值的方式得到句柄表项中的对象地址,然后将后三位清零后得到对象的头地址,
/
/
然后将头地址往下偏移就可以得到对象首地址
这里查看前面图中句柄值为
0x28
的内容:
kd> dq
0x8b4a0000
+
0x28
*
2
8b4a0050
000f01ff
`
87b3f329
000f037f
`
87b3ea41
8b4a0060
00020019
`a7a133c9
00000001
`a8795619
8b4a0070
00000804
`
86e6b0d9
00000804
`
86de5291
8b4a0080
00000804
`
88002da1
00000804
`
86de0b99
8b4a0090
00000804
`
87cb48d1
00000804
`
88002c39
8b4a00a0
00000804
`
86cee6f9
00000804
`
880bb271
8b4a00b0
00000804
`
86db1779
001f0001
`
87c722a9
8b4a00c0
001f0003
`
87bccf21
001f0001
`
87c46831
得到句柄表项中的对象值为
87b3f329
将前三位清零后为:
87b3f328
往下偏移
0x18
后为:
87b3f340
查看对象内容:
kd> !
object
87b3f340
Object
:
87b3f340
Type
: (
866f67a0
) Desktop
ObjectHeader:
87b3f328
(new version)
HandleCount:
12
PointerCount:
689
Directory
Object
:
00000000
Name: Default
/
/
通过句柄表
+
偏移值的方式得到句柄表项中的对象地址,然后将后三位清零后得到对象的头地址,
/
/
然后将头地址往下偏移就可以得到对象首地址
这里查看前面图中句柄值为
0x28
的内容:
kd> dq
0x8b4a0000
+
0x28
*
2
8b4a0050
000f01ff
`
87b3f329
000f037f
`
87b3ea41
8b4a0060
00020019
`a7a133c9
00000001
`a8795619
8b4a0070
00000804
`
86e6b0d9
00000804
`
86de5291
8b4a0080
00000804
`
88002da1
00000804
`
86de0b99
8b4a0090
00000804
`
87cb48d1
00000804
`
88002c39
8b4a00a0
00000804
`
86cee6f9
00000804
`
880bb271
8b4a00b0
00000804
`
86db1779
001f0001
`
87c722a9
8b4a00c0
001f0003
`
87bccf21
001f0001
`
87c46831
得到句柄表项中的对象值为
87b3f329
将前三位清零后为:
[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)
最后于 2023-5-10 11:40
被SnA1lGo编辑
,原因: 细节错误