复盘一下强网决赛的Reverse题。
<!--more-->
附件下载
题目名字已经很明显的告诉你了,就是 vm 逆向。
入口其实没啥,就是输入 32 长度的 passcode 然后校验,启动方式是 ./secret_box.exe quest
命令行传参。
可以找到最关键的函数 sub_140001D30
就是 VM 入口。
这个函数里面很明显的 vm_handler
不难发现,v3 是所谓的 opcode,v6 是 PC 指针,并且 vmcode 是实际的字节码 - 0x10
,下面来一个个分析这些 vm 的指令。
首先是 0 号指令,做了一个较为复杂的指针操作。这里初看可能啥也看不明白,但是可以发现最下面它分配了 0x10 的空间同时又 v7 和 v8 做模运算了赋值给 v11 指向的地址。
操作完成之后又执行了 *((_QWORD *)v11 + 1) = v2;
和 v2 = v11;
,如此种种的迹象显然不难让人联想到一种结构:链表。如果将划分的 0x10 字节内存进行划分,也可以看出,前八个字节存储数据,后八个字节存储指针。
shift+F1
打开 IDA 的 local type
窗口,按 insert
键插入结构体的定义
将 v11 和 v2 定义修改之后,IDA 将展示如下的伪代码:
可以说,基本上是一目了然了,并且据此可以联想到一个栈的结果,将两个数 push 进栈中,再弹出来做计算,计算的结果重新入栈。
这里可以将所有 malloc 返回值接收的变量都改成 LinkEntry *
类型。
此时再来观察整个反编译的代码
恢复结构体之后这个 vm 还是很一目了然的,下面解释一下各个 opcode 的作用。
同时可以根据操作自己实现虚拟机,这里已经很清楚是栈的数据结构了就直接用 std::stack
实现即可。
下面节选一段log
最前面事实上就是输出一句话 Thank for providing passcode, my ultimate secret box is checking...
用的,跳过之后就能看到。其中最明显的应该能看到它频繁的取输入字符并做 (x>>y)&1
的运算,y 从 0~7
,不难想到,这是在一个一个取出输入字节的每一位,每一位都对应了一个权值。第一个字节可以看出来,从低位到高位权值分别为
而最后,它将所有权值相加,得到的结果和 70 做异或运算得到 24,将该值存入栈底。
而把log拉到最后发现
我们所计算的第一个异或值,在最后一刻被加起来返回了。
而外面判断我们的输入是否正确,依赖于返回值是否为 0,因此我们要尽可能让每次异或值都相等,这里用个小技巧,将要输出的值打印到 stderr 中,再用重定向 2>out.txt
就可以快速拿到一些值。
首先我们拿异或的目标值,在异或的 opcode 中加入 fprintf(stderr,"%d,",reg2);
,得到值。
然后拿每一个字节的每一位的权值,在 *
运算中加入 fprintf(stderr,"%d,",reg2);
,得到值。
最终根据逻辑,写出还原 passcode 的逻辑。
输入后得到 flag
。
最后注意一下,这个反调试有点隐蔽,只要中了反调试就会修改 quest 这个文件,如果你不注意文件修改日期的话那么永远也算不出正确答案了。
如何发现反调试?通常情况下,我们不会刻意地去注意反调试,只有当程序提示,报错,运行结果附加调试器与不附加调试器运行结果有较大差异时,才会去注意反调试。
这里程序初始化的时候会拉起反调试,不过需要非常仔细,能够敏锐地观察到 vm 的代码文件被修改了。
首先确定反调试的位置,在main函数下断点,检查文件是否被修改。
发现main函数之前反调试就运行完了,那么就要讲到 main 函数之前调用的代码了,在 Linux 中,会保存一个 init_array,它会保存一系列的函数指针,这些函数先于 main 被调用;同样的,在 windows 中也有类似的。
通过 initterm 函数交叉引用找到 First 指针,获取函数指针的起始地址。
在指向的函数中,sub_140001190
是反调试的关键函数
其中对 FileName 变量似乎在做一个异或解密的操作,异或的key是0x69,dump下来解密之后发现果然是在对目标文件进行操作。
下面一系列就是写这个文件了,不过在这之前有一个关键判断 qword_14002ED10
,这个函数指针保存了哪个函数,大概率是 IsDebuggerPresent 了,但是还是去验证一遍。
交叉找到赋值的位置,是在这之前执行的函数内容。
下断点,看看能否断在这里,发现果真如我们所料
那么这个反调试的过程就是在main函数之前先执行了两个函数,一个函数获取 IsDebuggerPresent 函数的地址保存在全局变量中,第二个函数调用这个 API 判断,如果的确被调试那么修改 quest 的文件内容。
对于这个绕过直接上 xdbg 的反调试插件 or 修改文件名,队爹就是直接上 xdbg 甚至感受不到反调试的存在,而我狂踩坑...
附件下载,解压密码 2024qwbfinal
。
作者在此申明:本题目为类勒索病毒分析的题目,若要分析请一定一定不要在个人电脑或者公共电脑上运行此程序,请在虚拟机中调试分析,若因此造成任何的损失与作者无关。
以下是原题目描述
小Y玩游戏很菜,于是他找了个神秘人要了一个修改器文件,在开启功能后,发现他的一个重要文件居然被加密了,你能想办法帮他恢复吗?
请不要在物理机上运行题目中的任何文件,主办方对由此造成的任何损失不承担任何责任,如有需要请在虚拟机内进行运行和调试,解压密码:2024qwbfinal
压缩包给了两个文件,一个是 CT 脚本,一个是 .pdf.yr,看起来 .yr 是一个勒索了 pdf 类型文件的后缀。
先看看 CT 脚本,运行之后会拉起 DBK 驱动,运行计算器,并且看标题似乎是一个植物大战僵尸的修改器。
其中比较主要的就是运行了一个 decodeFunction
去解密一段函数运行,这里可以直接用网上的脚本还原这段。
运行后得到一个 luac 文件,luac 文件需要用另一个工具去还原为 lua 脚本,这里我是用的是 unluac_2023_12_24.jar
,同样附上下载地址:https://sourceforge.net/projects/unluac/files/Unstable/
前面都是一些赋值函数,拉到最后发现几个有意思的字符串,其中 C:\\system.dll
引起了注意,于是去对应目录下,能找到一个 dll 文件,那么毫无疑问,剩下的就是对 system.dll 进行分析了,lua 脚本应该就是做注入用的。
dllmain 一个很标准的起线程的动作
这个 StartAddress 就比较有意思,一直执着于判断自身某个内存的标志位,循环,而循环体内就是一直在 Sleep。
中间用 FindCrypt 发现 AES 的模式。
那么毫无疑问,勒索的文件应该是使用 AES 加密的,交叉找到对应的函数,其中一个是 10001840
,另一个是 10001790
。
静态分析比较难了,下面开始动态分析。
因为是个 dll,还不像 exe 那样好调试,这里我写了一个简单的 demo
然后直接远线程注入这个模块,为了能断下,选择 patch dll 的 dllmain 函数开头为 int 3
然后,虚拟机里
x32 起被调试程序,用注入器注入这个 dll。
程序成功被断下来
这里可以去恢复 int 3 指令然后重新执行一遍。
如果不改那个标志位,发现 while ( !byte_1000C6DC )
将会是一个死循环,这里估计是要等某个合适的时机,因此找到这个标志位将它赋值为1。
跳出循环之后,执行了一个函数
看字符串,获取了用户名,又有 Documents 字符串,猜测是对我们用户目录下的文档文件感兴趣,这里可以随意写几个pdf文件让它加密看看它的规律。
往后跟进几步,发现关键字符串
往后跟进之后发现,抛异常了,不过很幸运的是,栈中有数据提示我们
文件大小需要时 16 的倍数,那就换一个 16 个 a 的 pdf 文件再来一次。
随后调试到调用 AES 函数之前,这里需要分析一下这个参数的作用,调用约定属于 thiscall,this 指针存 ECX,其余参数从右往左压入栈中。
这里记录一下第三个参数指向的一片内存,这个内存每次运行都不一样,先记录一下。
C3 67 B7 93 5E 0D AB 9A 48 3D BA EB 65 5F B5 92
而第二个参数就是指向了一片 0xBAADF00D
的内存。运行放过去,同时火绒剑也查获了一下这个 dll 的行为。
加密文件,删除文件,典型的勒索病毒,同时也注意到多次运行的加密结果是不一样的,而且原本16字节的结果变成了48字节,AES就算是 padding 模式也不会多 32 字节,于是断定它必然是随机加密,而密钥肯定也保存到了文件里,这里 AES 的参数很有可能就是密钥。
其实原本这里有点山穷水尽了,后面的结论要得出来对我就比较看运气(高手肉眼就看出来)了。
偶然的情况下,上面的第二个参数生成了 00 字节,而对应的第二行位置上生成了 0x5A,又联想到之前 lua 脚本写过一段异或 0x5a 的脚本。
相对应地,去源码中找到这一段 system.dll+25C6
,在此下断点调试。
发现它在异或 0068D14D 的内存,异或了 0x10 个字节,难道说这就是所看到的密钥,验证一遍
发现果然就是它会将真实密钥每个字节异或 0x5A 之后保存到倒数第二行,那么最后一行必然不可能是 padding,猜测应该是初始向量,这是一个 CBC 模式的 AES。
通过研究 lua 脚本还发现,它似乎还对 DLL 进行了 hook,并且使用了 WriteByte 将 system.dll+C6DC
写为了 1,好样的,就是前面死循环的条件 while(!byte_1000C6DC)
,在这一刻完成了闭环。这个 dll 不仅用 lua 进行注入,还进行了一定的 hook,直接运行分析样本可能真分析不太出来,其实这里猜也能猜个大概了,但是作者这里觉得还是力求分析完整这个样本。
首先看看 initterm 函数的函数指针,发现了一些有意思的函数
使用 std::_Random_device
获取随机数,随后使用梅森旋转算法计算后续的随机数,这里 0x6C078965
是该算法的一个常数,搜也是能搜出来的。
而随后将计算出的这么多随机数,使用一定的算法将某 0x10 个字节赋值到了 this 指针,一共调用了两次这个函数,一个在 sub_10001000
,另一个在 sub_10001020
。赋值的全局变量分别在 1000C6E0
和 1000C6EC
,这个是一开始就生成好的。
自己再去调试一遍也可以验证得到 1000C6E0
指针所指向的值,就是被异或加密前的密钥,或者说就是 pdf 倒数第二行异或 0x5A 的结果,而最后一行的结果与 1000C6EC
指针所指向的内容是一致的。
因此可以验证一遍:
确定没问题之后就可以开始恢复 pdf 文件了,但是因为我的 system.dll 没有做 hook,而 lua 脚本运行的时候做了 hook,因此在题目加密的 pdf 文件中,要先交换高低半个字节,再异或,才是原始密钥。
先写一下解密 key 的脚本:
拿得到的结果去解密
可以发现已经是一个 pdf 文件头了,下载,打开查看,flag 到手。
还有一道 bvp47 也是一道恶意样本分析的题,但是目前精力有限,可能要咕很久才能做出来了233。
__int64
__fastcall vmrun(
char
*input,
char
*vmcode)
{
v2 = 0LL;
v3 = *vmcode - 16;
v5 = v48;
v6 = vmcode + 1;
while
( 2 )
{
switch
( v3 )
{
case
0u:
if
( v2 )
{
v9 = v2;
v2 = (
signed
int
*)*((_QWORD *)v2 + 1);
v7 = *v9;
free
(v9);
if
( v2 )
{
v10 = v2;
v2 = (
signed
int
*)*((_QWORD *)v2 + 1);
v8 = *v10;
free
(v10);
}
else
{
v8 = 0x80000000;
}
}
else
{
v7 = 0x80000000;
v8 = 0x80000000;
}
v11 = (
signed
int
*)j__malloc_base(0x10uLL);
*v11 = v7 % v8;
goto
LABEL_53;
case
1u:
LABEL_53:
*((_QWORD *)v11 + 1) = v2;
v2 = v11;
}
}
}
__int64
__fastcall vmrun(
char
*input,
char
*vmcode)
{
v2 = 0LL;
v3 = *vmcode - 16;
v5 = v48;
v6 = vmcode + 1;
while
( 2 )
{
switch
( v3 )
{
case
0u:
if
( v2 )
{
v9 = v2;
v2 = (
signed
int
*)*((_QWORD *)v2 + 1);
v7 = *v9;
free
(v9);
if
( v2 )
{
v10 = v2;
v2 = (
signed
int
*)*((_QWORD *)v2 + 1);
v8 = *v10;
free
(v10);
}
else
{
v8 = 0x80000000;
}
}
else
{
v7 = 0x80000000;
v8 = 0x80000000;
}
v11 = (
signed
int
*)j__malloc_base(0x10uLL);
*v11 = v7 % v8;
goto
LABEL_53;
case
1u:
LABEL_53:
*((_QWORD *)v11 + 1) = v2;
v2 = v11;
}
}
}
struct
LinkEntry{
signed
val;
LinkEntry * next;
}
struct
LinkEntry{
signed
val;
LinkEntry * next;
}
case
0u:
if
( v2 )
{
v9 = &v2->val;
v2 = v2->next;
v7 = *v9;
free
(v9);
if
( v2 )
{
v10 = &v2->val;
v2 = v2->next;
v8 = *v10;
free
(v10);
}
else
{
v8 = 0x80000000;
}
}
else
{
v7 = 0x80000000;
v8 = 0x80000000;
}
v11 = (LinkEntry *)j__malloc_base(0x10uLL);
v11->val = v7 % v8;
goto
LABEL_53;
case
0u:
if
( v2 )
{
v9 = &v2->val;
v2 = v2->next;
v7 = *v9;
free
(v9);
if
( v2 )
{
v10 = &v2->val;
v2 = v2->next;
v8 = *v10;
free
(v10);
}
else
{
v8 = 0x80000000;
}
}
else
{
v7 = 0x80000000;
v8 = 0x80000000;
}
v11 = (LinkEntry *)j__malloc_base(0x10uLL);
v11->val = v7 % v8;
goto
LABEL_53;
__int64
__fastcall vmrun(
char
*input,
char
*vmcode)
{
LinkEntry *v2;
unsigned
int
opcode;
unsigned
int
v5;
char
*PC;
signed
int
v7;
signed
int
v8;
signed
int
*v9;
signed
int
*v10;
LinkEntry *v11;
int
v12;
LinkEntry *v13;
unsigned
int
*v14;
unsigned
int
v15;
unsigned
int
v16;
unsigned
int
*v17;
unsigned
int
*v18;
LinkEntry *v19;
unsigned
int
v20;
unsigned
int
v21;
unsigned
int
*v22;
unsigned
int
*v23;
int
v24;
int
v25;
LinkEntry *v26;
int
v27;
int
v28;
signed
int
*v29;
int
*v30;
LinkEntry *v31;
unsigned
int
v32;
unsigned
int
v33;
unsigned
int
*v34;
unsigned
int
*v35;
LinkEntry *v36;
unsigned
int
v37;
unsigned
int
v38;
unsigned
int
*v39;
unsigned
int
*v40;
LinkEntry *v41;
signed
int
v42;
signed
int
v43;
signed
int
*v44;
signed
int
*v45;
int
v46;
unsigned
int
v48;
v2 = 0LL;
opcode = *vmcode - 16;
v5 = v48;
PC = vmcode + 1;
while
( 2 )
{
switch
( opcode )
{
case
0u:
if
( v2 )
{
v9 = &v2->val;
v2 = v2->next;
v7 = *v9;
free
(v9);
if
( v2 )
{
v10 = &v2->val;
v2 = v2->next;
v8 = *v10;
free
(v10);
}
else
{
v8 = 0x80000000;
}
}
else
{
v7 = 0x80000000;
v8 = 0x80000000;
}
v11 = (LinkEntry *)j__malloc_base(0x10uLL);
v11->val = v7 % v8;
goto
LABEL_53;
case
1u:
v12 = *PC;
v13 = (LinkEntry *)j__malloc_base(0x10uLL);
++PC;
v13->next = v2;
v2 = v13;
v13->val = v12;
goto
LABEL_54;
case
2u:
if
( v2 )
{
v14 = (unsigned
int
*)v2;
v2 = v2->next;
v5 = *v14;
free
(v14);
}
else
{
v5 = 0x80000000;
}
goto
LABEL_54;
case
3u:
if
( v2 )
{
v17 = (unsigned
int
*)v2;
v2 = v2->next;
v15 = *v17;
free
(v17);
if
( v2 )
{
v18 = (unsigned
int
*)v2;
v2 = v2->next;
v16 = *v18;
free
(v18);
}
else
{
v16 = 0x80000000;
}
}
else
{
v15 = 0x80000000;
v16 = 0x80000000;
}
v19 = (LinkEntry *)j__malloc_base(0x10uLL);
v19->next = v2;
v2 = v19;
v19->val = v15 * v16;
goto
LABEL_54;
case
4u:
if
( v2 )
{
v22 = (unsigned
int
*)v2;
v2 = v2->next;
v20 = *v22;
free
(v22);
if
( v2 )
{
v23 = (unsigned
int
*)v2;
v2 = v2->next;
v21 = *v23;
free
(v23);
}
else
{
v21 = 0x80000000;
}
}
else
{
v20 = 0x80000000;
v21 = 0x80000000;
}
v11 = (LinkEntry *)j__malloc_base(0x10uLL);
v24 = v21 + v20;
goto
LABEL_52;
case
5u:
sub_1400011B0(
"%c"
, v5);
goto
LABEL_54;
case
6u:
v25 = *input;
v26 = (LinkEntry *)j__malloc_base(0x10uLL);
v26->next = v2;
v2 = v26;
v26->val = v25;
goto
LABEL_54;
case
7u:
if
( v2 )
{
v29 = &v2->val;
v2 = v2->next;
v27 = *v29;
free
(v29);
if
( v2 )
{
v30 = &v2->val;
v2 = v2->next;
v28 = *v30;
free
(v30);
}
else
{
v28 = 0x80000000;
}
}
else
{
LOBYTE(v27) = 0;
v28 = 0x80000000;
}
v31 = (LinkEntry *)j__malloc_base(0x10uLL);
v31->next = v2;
v2 = v31;
v31->val = (v28 >> v27) & 1;
goto
LABEL_54;
case
8u:
if
( v2 )
{
v34 = (unsigned
int
*)v2;
v2 = v2->next;
v32 = *v34;
free
(v34);
if
( v2 )
{
v35 = (unsigned
int
*)v2;
v2 = v2->next;
v33 = *v35;
free
(v35);
}
else
{
v33 = 0x80000000;
}
}
else
{
v32 = 0x80000000;
v33 = 0x80000000;
}
v36 = (LinkEntry *)j__malloc_base(0x10uLL);
v36->next = v2;
v2 = v36;
v36->val = v32 ^ v33;
goto
LABEL_54;
case
9u:
++input;
goto
LABEL_54;
case
0xAu:
return
v5;
case
0xBu:
if
( v2 )
{
v39 = (unsigned
int
*)v2;
v2 = v2->next;
v37 = *v39;
free
(v39);
if
( v2 )
{
v40 = (unsigned
int
*)v2;
v2 = v2->next;
v38 = *v40;
free
(v40);
}
else
{
v38 = 0x80000000;
}
}
else
{
v37 = 0x80000000;
v38 = 0x80000000;
}
v41 = (LinkEntry *)j__malloc_base(0x10uLL);
v41->next = v2;
v2 = v41;
v41->val = v37 - v38;
goto
LABEL_54;
case
0xCu:
if
( v2 )
{
v44 = &v2->val;
v2 = v2->next;
v42 = *v44;
free
(v44);
if
( v2 )
{
v45 = &v2->val;
v2 = v2->next;
v43 = *v45;
free
(v45);
}
else
{
v43 = 0x80000000;
}
}
else
{
v42 = 0x80000000;
v43 = 0x80000000;
}
v11 = (LinkEntry *)j__malloc_base(0x10uLL);
v24 = v42 / v43;
LABEL_52:
v11->val = v24;
LABEL_53:
v11->next = v2;
v2 = v11;
LABEL_54:
v46 = *PC++;
opcode = v46 - 16;
if
( opcode <= 0xC )
continue
;
goto
LABEL_57;
default
:
LABEL_57:
sub_1400011B0(
"WTF are u doinggg..."
);
exit
(1);
}
}
}
__int64
__fastcall vmrun(
char
*input,
char
*vmcode)
{
LinkEntry *v2;
unsigned
int
opcode;
unsigned
int
v5;
char
*PC;
signed
int
v7;
signed
int
v8;
signed
int
*v9;
signed
int
*v10;
LinkEntry *v11;
int
v12;
LinkEntry *v13;
unsigned
int
*v14;
unsigned
int
v15;
unsigned
int
v16;
unsigned
int
*v17;
unsigned
int
*v18;
LinkEntry *v19;
unsigned
int
v20;
unsigned
int
v21;
unsigned
int
*v22;
unsigned
int
*v23;
int
v24;
int
v25;
LinkEntry *v26;
int
v27;
int
v28;
signed
int
*v29;
int
*v30;
LinkEntry *v31;
unsigned
int
v32;
unsigned
int
v33;
unsigned
int
*v34;
unsigned
int
*v35;
LinkEntry *v36;
unsigned
int
v37;
unsigned
int
v38;
unsigned
int
*v39;
unsigned
int
*v40;
LinkEntry *v41;
signed
int
v42;
signed
int
v43;
signed
int
*v44;
signed
int
*v45;
int
v46;
unsigned
int
v48;
v2 = 0LL;
opcode = *vmcode - 16;
v5 = v48;
PC = vmcode + 1;
while
( 2 )
{
switch
( opcode )
{
case
0u:
if
( v2 )
{
v9 = &v2->val;
v2 = v2->next;
v7 = *v9;
free
(v9);
if
( v2 )
{
v10 = &v2->val;
v2 = v2->next;
v8 = *v10;
free
(v10);
}
else
{
v8 = 0x80000000;
}
}
else
{
v7 = 0x80000000;
v8 = 0x80000000;
}
v11 = (LinkEntry *)j__malloc_base(0x10uLL);
v11->val = v7 % v8;
goto
LABEL_53;
case
1u:
v12 = *PC;
v13 = (LinkEntry *)j__malloc_base(0x10uLL);
++PC;
v13->next = v2;
v2 = v13;
v13->val = v12;
goto
LABEL_54;
case
2u:
if
( v2 )
{
v14 = (unsigned
int
*)v2;
v2 = v2->next;
v5 = *v14;
free
(v14);
}
else
{
v5 = 0x80000000;
}
goto
LABEL_54;
case
3u:
if
( v2 )
{
v17 = (unsigned
int
*)v2;
v2 = v2->next;
v15 = *v17;
free
(v17);
if
( v2 )
{
v18 = (unsigned
int
*)v2;
v2 = v2->next;
v16 = *v18;
free
(v18);
}
else
{
v16 = 0x80000000;
}
}
else
{
v15 = 0x80000000;
v16 = 0x80000000;
}
v19 = (LinkEntry *)j__malloc_base(0x10uLL);
v19->next = v2;
v2 = v19;
v19->val = v15 * v16;
goto
LABEL_54;
case
4u:
if
( v2 )
{
v22 = (unsigned
int
*)v2;
v2 = v2->next;
v20 = *v22;
free
(v22);
if
( v2 )
{
v23 = (unsigned
int
*)v2;
v2 = v2->next;
v21 = *v23;
free
(v23);
}
else
{
v21 = 0x80000000;
}
}
else
{
v20 = 0x80000000;
v21 = 0x80000000;
}
v11 = (LinkEntry *)j__malloc_base(0x10uLL);
v24 = v21 + v20;
goto
LABEL_52;
case
5u:
sub_1400011B0(
"%c"
, v5);
goto
LABEL_54;
case
6u:
v25 = *input;
v26 = (LinkEntry *)j__malloc_base(0x10uLL);
v26->next = v2;
v2 = v26;
v26->val = v25;
goto
LABEL_54;
case
7u:
if
( v2 )
{
v29 = &v2->val;
v2 = v2->next;
v27 = *v29;
free
(v29);
if
( v2 )
{
v30 = &v2->val;
v2 = v2->next;
v28 = *v30;
free
(v30);
}
else
{
v28 = 0x80000000;
}
}
else
{
LOBYTE(v27) = 0;
v28 = 0x80000000;
}
v31 = (LinkEntry *)j__malloc_base(0x10uLL);
v31->next = v2;
v2 = v31;
v31->val = (v28 >> v27) & 1;
goto
LABEL_54;
case
8u:
if
( v2 )
{
v34 = (unsigned
int
*)v2;
v2 = v2->next;
v32 = *v34;
free
(v34);
if
( v2 )
{
v35 = (unsigned
int
*)v2;
v2 = v2->next;
v33 = *v35;
free
(v35);
}
else
{
v33 = 0x80000000;
}
}
else
[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!