首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

分布式快速端口扫描的任务调度算法与协议研究

摘要:随着端口扫描技术的发展,一些如ZMap、MASSCAN等的单节点快速端口扫描工具相继被提出,从而最大限度地利用带宽资源进行端口扫描。然而,这些工具的性能受制于单节点的带宽,提升空间较为有限。为了进一步提升这些工具的性能,提出了一种分布式任务调度算法及其配套协议,可用于实现一套分布式的快速端口扫描系统。该算法将目标地址进行分片后,下发给所有可用的扫描节点同时进行端口扫描,理论上能够利用所有节点的带宽资源。此外,该算法还具备一些异常处理机制,用于保证系统的可用性和结果的准确性。

正文内容:

0 引言

随着互联网的蓬勃发展,接入互联网的设备数量呈现快速增长趋势,其中不乏一些关键基础设施(Critical Infrastructure,CI)[1]。互联网的接入固然带来了效率提升,但也为原来的系统引入新的攻击。一些高级持续性威胁(Advanced Persistent Threat,APT)[2]往往利用其中一些存在脆弱性(Vulnerability)的网络设备进行初步攻击。此外,一部分网络设备可能还将沦落为攻击者的利用工具,如2016年爆发的Mirai僵尸网络事件[3]等。如果受攻击的对象为一些关键基础设施,社会的运行秩序必定受到威胁。

在日益增长的网络空间安全需求下,快速采集这些网络设备的基本信息成为具有特殊意义的事情。一般情况下,相关维护人员可通过采集到的网络设备信息,对当前网络的安全性进行有数据支撑的评估;在重大漏洞被披露时,安全响应人员也能通过这些信息快速定位受影响的设备,并根据影响范围制定针对性的修复方案,将影响降至最低。为了满足快速采集信息的要求,本文提出一种用于适用于分布式快速端口扫描的任务调度算法与协议,结合现有的无状态端口扫描技术,能够大幅度提升端口扫描的效率,进而提升网络设备信息采集的速度。

1 背景介绍

为了面向整个互联网进行信息探测,首先需要对互联网中的网络设备进行快速端口扫描。传统的TCP端口扫描由于需要完成完整的TCP握手过程,系统内核中也需要维护当前的TCP连接的一些状态,导致其进行端口扫描时速度较慢,内存消耗也比较明显。由于这些缺陷,一些不需要系统内核维持TCP状态的扫描工具相继被设计。其中,最具代表性的有:在1 Gb带宽下,能够在45 min内完成整个IPv4地址空间扫描的ZMap[4];在10 Gb带宽环境下,能够在6 min内进行全网端口扫描的MASSCAN[5]。然而,这些工具的缺点也较为明显,主要体现在以下两点:

(1)这些工具的扫描速率受限于带宽,而带宽与成本的关系并不是线性的,而是较高的扫描速率意味着更高的成本。

(2)频繁扫描某个目标网段,可能会触发目标网络的边界防火墙,影响结果。ZMap通过伪随机的方式避免在较短时间内连续扫描通过网段[6],但由于ZMap为单节点扫描器,在扫描速率过高时仍然可能导致出口IP被目标网段的防火墙所封禁。

为了解决上述两个问题,本文采用一种分布式的端口扫描架构。该架构采用多节点共同扫描,增加节点数,即可接近线性地提升整个架构的扫描效率。此外,由于采用多节点机制,可通过一些调度算法,将连续网段的扫描任务分摊给多个节点,降低被目标网段封禁的概率。为了使得该分布式架构能够高效工作,本文研究并提出了与之相匹配的一套任务调度算法与通信协议。该算法与协议具备以下优点:

(1)调度算法将连续IP地址的端口扫描通过任务分割下发给不同的节点执行,避免单节点因为扫描速率过高而受到封禁。此外,该算法生成的任务数据非常小。

(2)通信协议能够将构造尽可能小的数据包用于传递下发任务数据、任务执行结果以及其他的一些辅助数据包。

2 任务调度算法

2.1 整体架构

本文任务调度算法面向的架构如图1所示。该架构通过使用一个任务调度节点进行统一的任务调度工作,并负责将任务信息与任务执行结果存入数据库中。该架构的扫描节点仅需与任务调度节点建立通信。该架构的优点在于实现简单,只有两种类型的节点,且扫描节点之间是解耦和的。

一个端口扫描任务在该分布式系统中的执行可简化为如图2所示的流程。用户在通过任务调度节点下发一个扫描任务后,任务调度系统会使用本文所提出的算法切割该任务,并将切割后的子任务存入数据库中。在有空闲的扫描节点上线后,任务调度节点会将一部分子任务下发给这些节点,并由这些节点进行端口扫描任务。在扫描节点完成端口扫描后,扫描结果会重新经由任务调度节点存入数据库中。

由该系统的架构可看出,任务调度算法主要包含任务切割和任务调度两部分。

2.2 任务切割

任务切割指将待扫描的CIDR地址切割成若干个子集,主要作用是使多个扫描节点具备同时执行同一个扫描任务的能力,并且避免单个节点连续地、高频率地扫描同一个目标网段。定义待扫描的IPv4地址空间为集合A ,通过任务切割可生成i 个子集,第个子集记为Ai ,那么应满足:

条件一:且;

条件二:为了避免连续扫描某个网段的频率过高,Ai 还应满足其成员在A 中的分布足够均匀;

条件三:由于Ai 最终需要下发给扫描节点,而对于端口扫描任务而言,带宽是一类核心资源。承载Ai 的数据包如果太大,不但增加了节点的网络负担,而且更长的任务传输时间也严重影响整个系统的扫描效率。因此,承载Ai 的数据包应当是高度可压缩的,即应满足Ai 的熵足够低,可使用极少的数据单位表示。

如果采用随机化算法,即从A 从随机挑选一些成员用于构成Ai ,那么将不能满足条件一和条件三。

2.2.1 地址分片

ZMap的研究人员在一篇关于提升ZMap速度的论文中提出了地址生成分片(Address Generation Sharding)技术[6]。定义集合A 的大小为m ,其成员为a1,a2,…,am ,那么该技术切割出来的子集满足Ai= ,其中。该技术的初衷在于简化地址生成的计算量,消除ZMap内部的一个互斥锁。虽然它的主要目的是为了提升单节点扫描时的性能,但经过分析不难发现,该方法同时满足了条件一、条件二以及条件三的前半部分。如果能使得该方法满足所有条件,那么其将是一个非常好的适用于分布式的任务切割算法。

根据IANA发布的特殊用途IPv4地址[7],一部分IPv4地址被作为内网地址。该系统应具备由用户设定IPv4地址黑名单的功能,用于过滤特定IPv4地址的扫描结果。定义这些地址的集合为E ,那么在执行端口扫描时应排除E ,以提升扫描速度。为了达到此目的,下发给扫描节点i 进行扫描探测的实际任务数据应当为Ai-E ,难以使用极少的数据单位进行表示。

注意到,在进行任务下发时,下发给不同扫描节点的E 实际上一致的。当且仅当用户设定新的黑名单时,E 才会改变。因此,可设计一种机制,在用户设定新黑名单时,预先将E 推送给扫描节点。在该机制下,调度节点每次下发给任务节点的数据仅需包含Ai 。又因为Ai 仅需要第一个成员、成员的间隔与成员上限即可还原所有成员,那么该任务数据仅需3个数据单位 即可表示,满足条件三后半部分。调度节点进行地址分片与扫描节点进行扫描地址还原的伪代码如下:

function split_address(task_id,cidr,n):

first_addr,last_addr←cidr_to_integer(cidr)

for i←0…n-1:

task←

store_task(task)

function generate_addresses(task):

current_addr,last_addr,n←task

while current_addr≤last_addr:

yield integer_to_ipv4(current_addr)

current_addr←current_addr+n

2.2.2 分片优化

解决地址分片问题后,还得考虑分片数量n 的取值问题。n 如果比扫描节点的数量小,那么部分扫描节点可能无法分配到扫描任务,浪费了一部分节点的扫描能力;如果n 取值过大,那么将导致下发任务中的目标较少,扫描节点频繁与从调度节点请求任务数据,将浪费部分带宽资源。

为了充分利用各个扫描节点,可以将 设置成活跃扫描节点的数量。每当一个新的扫描节点连接调度节点时,n 的值加一。这样在不考虑其他印象因素的前提下,最终的地址分片数与节点的数量一致,保证了每个节点均能分配到相应的扫描任务。然而,这种方法仍有较大缺陷。考虑到两个扫描节点的带宽不可能完全一致,某些扫描节点的出口带宽可能有1 Gb/s,而另外一个节点仅有100 Mb/s,显然分配同样数量的IPv4地址是不合理的。为了解决这个问题,本文使用了一种加权分片的算法。

对于任意一个扫描节点Si ,其在连接调度节点前可预先被赋予一个权值

。当Si 连接调度节点时,Wi 被发送到调度节点并被后者所记录。调度节点在进行地址分片时,取n=W1+ W2+ W3+… ,并最终产生n 个IPv4的地址分片。向Si 下发扫描任务时,调度节点挑选出Wi 个地址分片,并将其组合成一个任务数据包,一次性推送给Si 。由于本算法中表示一个地址分片只需3个数据单位,则该任务数据包总共只需使用3xWi 个数据单位。

2.3 任务调度

为了实现分布式快速端口扫描,任务调度节点应当具备与所有可用的节点建立通信能力,并在有新任务时,将切割后的地址分片下发给各个扫描节点。为了保证扫描结果的准确性,当一个扫描节点出现异常时,任务调度节点应当具备某种机制,重新扫描受影响的地址分片。上述流程涉及到节点管理、任务通信与异常处理三个部分。

2.3.1 节点管理

扫描节点在开始工作前,首先会向任务调度节点建立一条TCP链路,并发送一个上线数据包,表明当前节点可以正常工作。上线数据包主要包含扫描节点Si 的ID与权值Wi 等信息。任务调度节点接收到上线数据包后,会将数据包中的信息和当前TCP链路保存在内存中的线性表Lstatus 中。这样一旦有新的扫描任务,任务调度节点便可通过遍历Lstatus 的方式寻找可用节点,并将地址分片通过对应的TCP链路推送给扫描节点。整个节点上线的过程由扫描节点主动发起,任务调度节点自动处理。因此,增删扫描节点的过程不影响其他扫描节点和任务调度节点。

网络通信并不是绝对可靠的,扫描节点也存在宕机的可能性。一旦某个扫描节点不可用,继续将地址分片下发给该节点显然是无意义的。因此,任务调度节点必须维护扫描节点的可用状态。为了反馈可用状态,扫描节点会每隔一段时间(记为Td )向任务调度节点发送一个心跳包,表明该节点一切正常。如果任务调度节点在一定时间(记为Tover )内未收到扫描节点Si 的心跳包,那么可认为Si 不可用。考虑到网络的时延、抖动等因素,Tover 的值应当大于Td 。本文中,取Tover =2.5xTd 。

如果一个扫描节点正在执行扫描任务,那么任务调度节点在进行任务下发时,应优先忽略这些节点。为了达成该目的,扫描节点发送给任务调度节点的心跳包中,还包含当前节点的忙碌状态。任务调度节点在接收到扫描节点Si 的心跳包后,会主动更新节点Lstatus 中对应的状态值,供任务下发过程参考。综上所述,线性表Lstatus 的关键结构如图3所示。

注意到,由于所有扫描节点的权值的总和n 决定了地址分片的数量,每当一个新扫描节点连接任务调度节点或一个既有的扫描节点出现宕机等状况时,n 的取值需要进行适当变更。

2.3.2 任务通信

进行任务下发时,任务调度节点知道目标扫描节点的权值Wi ,会直接从数据库中挑选Wi 个地址分片,与待扫描的端口号组合成一个数据包发送给扫描节点。扫描节点在完成这些分片的扫描探测任务后,情况略微有点区别。定义扫描节点Si 最终产生的扫描结果为Ri ,由于Si 扫描的地址分片不一定会产生扫描结果,Ri 可能是空的,也有可能包含若干条记录。

由于Ri 是变长的,存在两种特殊情况,一是Ri 为空集,二是Ri 的记录数非常大。对于第一种情况,扫描节点仍然需要向任务调度节点发送一个数据包,告诉后者当前扫描任务已经完成。对于第二种情况,如果将Ri 一次性返回给任务调度节点,那么网络传输时间相对较长。网络通信并不是绝对可靠的,扫描节点也并非绝对稳定。如果发生网络暂时不可用或节点宕机等情况,那么将导致所有的扫描结果丢失。此外,在第二种情况下,由于Ri 的体积较大,服务器在进行数据包构造或解析时,可能需要消耗大量的内存资源。为了解决上述问题,本文为每个回传给任务调度节点的数据包所包含的记录数设置一个上限,Ri 会被切割成若干个不超过该上限的子集分别发送给任务调度节点。该过程的伪代码如下:

function process_task(task):

Port←resolve_task_port(task)

while ipv4←generate_addresses(task):

result←scan(ipv4, port)

if result=Ø:

continue

put_into_buffer(result)

if buffer_reach_limit():

send_and_clear_buffer()

send_and_clear_buffer()

该过程的优势主要在于部分扫描结果可以预先返回给任务调度节点,扫描节点只需要维护一个相对较小的用于容纳扫描结果的缓冲区,有效降低了数据包在构造或解析时所需使用的内存资源。

2.3.3 异常处理

分布式快速端口扫描中可能出现的异常主要有两类,一类是因临时性通信问题引发的异常,另一类则是因节点宕机引发的异常。

对于第一类异常,由于临时性通信问题,扫描结果可能无法返回给任务调度节点。注意到,扫描节点Si 返回结果Ri 的过程时幂等的,即多次返回Ri 并不会影响最终结果的准确性。因此,处理该异常方式也较为简单,即发现异常时重新返回 Ri。扫描节点在回传结果数据包时,任务调度节点会返回一个确认数据包。如果扫描节点没有接收到对应的确认数据包,那么说明任务调度节点没有接收到对应的扫描结果,或者确认数据包在传输途中因为某种原因丢失。此时,扫描节点会重新发送该扫描结果,直到接收到对应的确认数据包。当然,扫描节点不会无限次重发该扫描结果,当重试次数超过一个预设的值时,该扫描结果即被丢弃。

对于第二类异常,扫描节点宕机将会导致下发给当前节点的扫描任务永远不会返回扫描结果。由于本文的任务调度算法中,扫描节点每隔一定的时间Td 就会向任务调度节点发送一个心跳包,当超过Tover 后仍未接收到节点的心跳包,即可认为节点宕机。因此,任务调度节点会不断检查线性表Lstatus 中上次心跳包的时间戳,一旦发现其与当前系统的时间戳的差值超过Tover 且对应节点处于忙碌状态,那么将其视为宕机,并将下发给对应节点的地址分片重新进行下发操作。

第二类异常中还存在一种特殊情况,如扫描节点在发送完一个心跳包后宕机,重启后又发送了一个心跳包,且两个心跳包的间隔不超过Tover 。这种情况下,上文中提及的异常处理机制不会被触发,之前下发给当前节点的扫描任务同样不会返回任何扫描结果。为了解决该问题,扫描节点在发送上线数据包时,任务调度节点会首先检查之前是否给当前节点下发过扫描任务,若是,则同样重新下发该扫描任务涉及的地址分片。

3 通信协议

提出的任务调度算法依赖于节点间的通信,因此与任务调度算法对应的,本文提出了一种基于TCP的通信协议。该通信协议在满足任务调度基本需求的基础上,着重于提升通信效率、保密性。此外,该通信协议还预留了一些用于版本兼容的特性。

3.1 通信协议的封装

TCP是一类数据流传输协议,而任务调度算法中发送数据以包为单位,因此本文提供了一种应用层的封装协议。该协议结构如图4所示。其中,版本号部分用于表示当前协议的版本号,当前协议中版本号为1;属性部分则用于表示当前数据包的一些属性,拥有4 bits的空间。由于任务调度算法中,不同的扫描节点拥有不同的ID,此处使用3 Bytes用于容纳客户端ID;时间戳与内容长度部分均为4 Bytes的无符号整数,分别用于表示发包时的Unix时间戳与数据部分的内容长度。

由于系统的迭代开发可能导致通信协议的变更,数据包头部定义了版本号部分可用于满足协议的向前、向后兼容性方面的需求。此外,属性部分目前只使用了3 bits,预留了1 bits用于未来增加一些新的数据包属性。这样即使未来增加了一些新属性,整个数据包的结构也不会发生较大变化。

当前协议版本中,属性部分的第1个比特位用于表示数据部分是否被压缩过。一些诸如扫描结果之类的数据包,数据部分体积可能相对较大,进行压缩后可以大幅度降低数据包的大小;而另外一些如用于下发任务数据的数据包,原始数据可能只需要十几个字节,压缩后的数据大小甚至可能超过原数据的体积,进行压缩显然没有必要。考虑到上述两种情况,数据部分可根据实际情况选择压缩与否。因此,本通信协议中使用了一个单独的属性位用于表示数据部分是否被压缩。当属性部分的第一个比特位为1时,表示数据部分使用GZIP[8]算法压缩过,在进行解析操作前,必须先对数据部分进行解压缩。

属性部分的第2个比特位目前恒置为0,为未来版本的新属性做预留。属性的最后两个字节用于表示数据包类型。目前,该任务调度算法中存在4种类型的单向数据包,分别为上线数据包、心跳包、任务数据包以及扫描结果数据包。为了区分不同的数据包,本协议将其严格按照上述顺序从0开始编号,故使用2个比特位即可区分4种数据包。若未来的协议版本中拥有更多类型的数据包,可考虑使用属性的第2个比特位。由于任务调度算法中的异常处理机制,正常情况下,每种数据包在发送后均会接收到一个与之相对应的确认数据包。为了区分不同的确认数据包,本协议严格遵循确认数据包的类型编号与原数据包始终一致的原则。以心跳包为例,其类型编号为1,扫描节点将心跳包发送给任务调度节点后,将接收到一个类型编号同样为1的确认数据包。

为了区分不同的扫描节点,每个扫描节点拥有一个与众不同的客户端ID。本协议中,使用单独的3字节表示该客户端ID。对于任务调度节点而言,本身不需要客户端ID,故客户端ID始终代表通信双方中扫描节点的ID。此外,数据包中还使用了一个单独的字段指明了发包时的时间戳。本协议依赖上述两个字段实现数据包的保密性与客户端的身份认证。本协议要求每个客户端ID对应不同的预置共享密钥(Pre-Shared Key,PSK),在进行数据传输时,必须使用PSK对原始数据进行AES加密。为了实现客户端的身份认证,本协议还要求原始数据的前4个字节必须与数据包头部中的时间戳部分完全一致。这样当接受数据包的一方使用PSK进行数据解密时,可通过前4个字节对发送方身份进行验证。

由于TCP是一类数据流传输协议,本协议在数据包头部设置了一个内容长度部分,用于确定当前数据包的边界。内容长度部分的取值即为数据部分的长度,单位为字节。一个合法的数据包中,数据部分的长度应与数据包头部中的内容长度完全一致。

3.2 数据包格式

通过应用层协议的封装,任务调度算法已经能够使用TCP链路发送数据包。出于保密性与身份认证考虑,应用层协议要求原始数据部分的前4个字节必须为一个Unix时间戳,后续字节则可根据实际数据包的类型采用不同的数据格式。为了适应不同数据包的不同格式,并最大限度地利用带宽资源,此处可以采用一些紧凑小巧的通用数据格式,如Apache Thrift[9]、Protocol Buffers[10]等。本协议采用相对小巧的Protocol Buffers。定义Unix时间戳为Dtimestamp ,实际承载数据的通用数据格式部分为Dprotobuf ,最终附加到图4中数据部分的数据为Data ,那么有:

特别地,如果属性部分的第一个比特位为1,那么还需要对Data 进行GZIP压缩。对于不同的数据包,其构造过程一致,唯一的区别在于Dprotobuf 部分。以任务数据包为例,由于其目的是从任务调度节点向扫描节点单向地传递任务数据,其Dprotobuf 的结构如下所示:

message AddressesShard{

fixed32 first_addr=1;

fixed32 last_addr=2;

uint32 n=3;

}

message TaskPackage{

uint32 port=1;

repeated AddressesShard shards=2;

}

特别地,所有类型的数据包均有与之对应的确认数据包,其作用完全一致,故确认数据包可采用相同的数据格式。格式如下,其中acknowledged用于指明数据是否被正常接收,msg则用于附加一些调试信息。

message AcknowledgePackage{

bool acknowledged=1;

string msg=2;

}

4 算法与协议评估

本文提出的算法能够利用所有可用的扫描节点对用户下发的目标进行快速端口扫描,且各个节点扫描的目标IPv4地址互不相同,最大限度地利用了所有扫描节点的扫描能力。此外,本文设计了一些异常处理机制,当扫描节点出现异常时,任务调度节点会自动进行任务的重下发,既保证了整个系统的可用性,也提升了扫描结果的准确性。

显然,由于算法本身的特性,扫描效率可以随着扫描节点的增加而提升。为了验证系统与通信协议的可用性、异常处理机制的有效性,本文基于上述算法与协议,使用Go语言实现了一个原型系统。该原型系统具备1个任务调度节点与2个扫描节点。其中,扫描节点接收到扫描任务后,会在一段随机时间后返回随机数量的扫描结果,用于模拟真实的端口扫描行为;当扫描节点完成当前扫描任务并接收到任务调度节点返回的确认数据包,扫描节点会将当前任务中所有的地址分片(记为)记录到一个日志文件中,用于验证分布式调度算法的准确性。

该原型系统的三个节点位于同一个路由器下边。该路由器会定期重启,用于模拟网络通信的不稳定性。在系统运行过程中,其中一个扫描节点会被人为地重启,用于模拟宕机行为。如果上述算法中的异常处理机制和通信协议能够正常工作,那么在上述环境下,用户下发的扫描任务应当能够正常完成,且满足:

本文使用该原型系统进行了10次独立实验,发现10次实验结果均满足上述条件,本文算法与协议的可用性、异常处理机制的有效性以及扫描结果的准确性得证。

5 结 语

本文提出了一套适用于分布式快速端口扫描的任务调度算法与协议。该算法与协议效率高,且具备一定的异常处理机制,保证了整个系统的可用性和准确性。该算法与协议实现简单,实证有效,对工程化实现有很高的参考价值。下一步研究将使用本文提出的算法与协议实现一个分布式的插件式快速端口扫描系统。

参考文献:

[1] Rinaldi S M,Peerenboom J P,Kelly T K.Identifying,Understanding,and Analyzing Critical Infrastructure Interdependencies[J].IEEE Control Systems,2001,21(06):11-25.

[2] 徐远泽,张文科,尹一桦等.APT攻击及其防御研究[J].通信技术,2015,48(06):740-745.

[3] Antonakakis M,April T,Bailey M,et al.Understanding the Mirai Botnet[C].26th USENIX Security Symposium,2017:1093-1110.

[4] Durumeric Z,Wustrow E,Halderman J A.ZMap:Fast Internet-wide Scanning and Its Security Applications[C].USENIX Security Symposium,2013(08):47-53.

[5] Graham R D.MASSCAN:Mass IP Port Scanner[EB/OL].[2017-07-22].http://github. com/robertdavidgraham/masscan.

[6] Adrian D,Durumeric Z,Singh G,et al.Zippier ZMap:Internet-Wide Scanning at 10 Gbps[C].WOOT,2014.

[7] IANA.IANA IPv4 Address Space Registry[EB/OL].[2017-07-22].http://www.iana.org/assignments/ipv4-address-space/ipv4-address-space.xml.

[8] Gailly J L,Adler M.The Gzip Home Page[EB/OL].[2017-07-22].http://www.gzip.org/.

[9] Apache Software Foundation.Apache Thrift[EB/OL].[2017-07-22].https://thrift.apache.org/.

[10]Varda K.Protocol Buffers:Google’s Data Interchange Format[J].Google Open Source Blog,2008(07):72.

作者:林培胜,王轶骏,薛 质

单位:上海交通大学 网络空间安全学院,上海 200240

作者简介:林培胜,男,硕士,主要研究方向为网络攻防技术;

王轶骏,男,硕士,讲师,主要研究方向为网络攻防及系统安全;

薛 质,男,博士,教授,主要研究方向为计算机通信网及信息安全。

本文刊登在《通信技术》第12期(转载请注明出处,否则禁止转载)

  • 发表于:
  • 原文链接http://kuaibao.qq.com/s/20171225B0J91D00?refer=cp_1026
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券