【Sa-Token】SpringBoot 整合 Sa-Token 快速实现 API 接口签名安全校验
在涉及跨系统接口调用时,我们容易碰到以下安全问题:
- 请求身份被伪造
- 请求参数被篡改
- 请求被抓包,然后重放攻击
sa-token api-sign
模块将帮你轻松解决以上难题。(此插件是内嵌到 sa-token-core
核心包中的模块,开发者无需再次引入其它依赖,插件直接可用)
假设我们有如下业务需求:
用户在 A 系统参与活动成功后,活动奖励以余额的形式下发到 B 系统。
1. 初始方案:直接裸奔
在不考虑安全问题的情况下,我们很容易完成这个需求:
1、在 B 系统开放一个接口
@RestController
@RequestMapping("/sign")
public class SignController {
@PostMapping("/addMoney")
public String addMoney(Long userId, Long money) {
// TODO 处理业务...
return "ADD SUCCESS";
}
}
2、在 A 系统使用 http 工具类调用这个接口
@RestController
@RequestMapping("/activity")
public class ActivityController {
@PostMapping("/join")
public String join() {
// 参加完活动后,发送余额
Long userId = 1L;
Long money = 100L;
Map<String, Object> params = new HashMap<>();
params.put("userId", userId);
params.put("money", money);
String url = "http://localhost:8079/sign/addMoney";
String result = HttpUtil.post(url, params);
return "join";
}
}
上述代码简单的完成了需求,但是很明显它有一个安全问题:
B 系统开放的接口不仅可以被 A 系统调用,还可以被其它任何人调用,甚至别人可以本地跑一个 for 循环调用这个接口,为自己无限充值金额
2. 方案升级:增加 secretKey 校验
为防止 B 系统开放的接口被陌生人任意调用,我们增加一个 secretKey 参数
@PostMapping("/addMoney")
public String addMoney(Long userId, Long money, String secretKey) {
// 校验 secretKey
if (!check(secretKey)) {
throw new RuntimeException("无效 secretKey,无法响应请求");
}
// TODO 处理业务...
return "ADD SUCCESS";
}
由于 A 系统是我们 “自己人”,所以它可以拿着 secretKey 进行合法请求:
@PostMapping("/join")
public String join() {
// 参加完活动后,发送余额
Map<String, Object> params = new HashMap<>();
params.put("userId", userId);
params.put("money", money);
params.put("secretKey", "×××××××××××");
String url = "http://localhost:8079/sign/addMoney";
String result = HttpUtil.post(url, params);
return "join";
}
现在,即使 B 系统的接口被暴露了,也不会被陌生人任意调用了,安全性得到了一定的保证,但是仍然存在一些问题:
- 如果请求被抓包,secretKey 就会泄露,因为每次请求都在 url 中明文传输了 secretKey 参数。
- 如果请求被抓包,请求的其它参数就可以被任意修改,例如可以将 money 参数修改为 9999999,B系统无法确定参数是否被修改过。
3.方案再升级:使用摘要算法生成参数签名
首先,在 A 系统不要直接发起请求,而是先计算一个 sign 参数:
@PostMapping("/join")
public String join() {
// 参加完活动后,发送余额
Long userId = 1L;
Long money = 100L;
String secretKey = "×××××××××××";
// 计算 sign
String sign = md5("money=" + money + "&userId=" + userId + "&key=" + secretKey);
Map<String, Object> params = new HashMap<>();
params.put("userId", userId);
params.put("money", money);
params.put("sign", sign);
String url = "http://localhost:8079/sign/addMoney";
String result = HttpUtil.post(url, params);
return "join";
}
注意:此处计算签名时,需要将所有参数按照字典顺序依次排列(key除外,挂在最后面)
然后在 B 系统接收请求时,使用同样的算法、同样的秘钥,生成 sign 字符串,与参数中 sign 值进行比较:
@PostMapping("/addMoney")
public String addMoney(Long userId, Long money, String sign) {
// 在 B 系统,使用同样的算法、同样的密钥,计算出 sign2,与传入的 sign 进行比对
String sign2 = md5("money=" + money + "&userId=" + userId + "&key=" + secretKey);
if (!sign2.equals(sign)) {
return "无效 sign,无法响应请求";
}
// TODO 处理业务...
return "ADD SUCCESS";
}
因为 sign 的值是由 userId、money、secretKey 三个参数共同决定的,所以只要有一个参数不一致,就会造成最终生成 sign
也是不一致的,所以,根据比对结果:
- 如果 sign 一致,说明这是个合法请求。
- 如果 sign 不一致,说明发起请求的客户端秘钥不正确,或者请求参数被篡改过,是个不合法请求。
此方案优点:
- 不在 url 中直接传递 secretKey 参数了,避免了泄露风险。
- 由于 sign 参数的限制,请求中的参数也不可被篡改,B 系统可放心的使用这些参数。
此方案仍然存在以下缺陷:
-
被抓包后,请求可以被无限重放,B 系统无法判断请求是真正来自于 A 系统发出的,还是被抓包后重放的。
@PostMapping(“/join”)
public String join() {
// 参加完活动后,发送余额
Long userId = 1L;
Long money = 100L;
String nonce = SaFoxUtil.getRandomString(32); // 随机32位字符串
String secretKey = “×××××××××××”;
// 计算 sign
String sign = md5(“money=” + money + “&nonce=” + nonce + “&userId=” + userId + “&key=” + secretKey);
Map<String, Object> params = new HashMap<>();
params.put(“userId”, userId);
params.put(“money”, money);
params.put(“nonce”, nonce);
params.put(“sign”, sign);
String url = “http://localhost:8079/sign/addMoney”;
String result = HttpUtil.post(url, params);
return “join”;
}
4. 方案再再升级:追加 nonce 随机字符串
首先,在 A 系统发起调用前,追加一个 nonce 参数,一起参与到签名中:
public String join() {
// 参加完活动后,发送余额
Long userId = 1L;
Long money = 100L;
String nonce = SaFoxUtil.getRandomString(32); // 随机32位字符串
String secretKey = "×××××××××××";
// 计算 sign
String sign = md5("money=" + money + "&nonce=" + nonce + "&userId=" + userId + "&key=" + secretKey);
Map<String, Object> params = new HashMap<>();
params.put("userId", userId);
params.put("money", money);
params.put("nonce", nonce);
params.put("sign", sign);
String url = "http://localhost:8079/sign/addMoney";
String result = HttpUtil.post(url, params);
return "join";
}
然后在 B 系统接收请求时,也把 nonce 参数加进去生成 sign 字符串,进行比较:
public String addMoney(Long userId, Long money, String nonce,String sign) {
// 检查此 nonce 是否已被使用过了
if (Objects.nonNull(CacheUtil.get("nonce_" + nonce))) {
return "此 nonce 已被使用过了,请求无效";
}
// 在 B 系统,使用同样的算法、同样的密钥,计算出 sign2,与传入的 sign 进行比对
String sign2 = md5("money=" + money + "&nonce=" + nonce + "&userId=" + userId + "&key=" + secretKey);
if (!sign2.equals(sign)) {
return "无效 sign,无法响应请求";
}
// 存入缓存
CacheUtil.set("nonce_" + nonce, "1");
// TODO 处理业务...
return "ADD SUCCESS";
}
代码分析:
- 为方便理解,我们先看第 3 步:此处在校验签名成功后,将 nonce 随机字符串记入缓存中。
- 再看第 1 步:每次请求进来,先查看一下缓存中是否已经记录了这个随机字符串,如果是,则立即返回:无效请求。
这两步的组合,保证了一个 nonce 随机字符串只能被使用一次,如果请求被抓包后重放,是无法通过 nonce 校验的。
至此,问题似乎已被解决了 …… 吗?
别急,我们还有一个问题没有考虑:这个 nonce 在字符串在缓存应该被保存多久呢?
- 保存 15 分钟?那抓包的人只需要等待 15 分钟,你的 nonce 记录在缓存中消失,请求就可以被重放了。
- 那保存 24 小时?保存一周?保存半个月?好像无论保存多久,都无法从根本上解决这个问题。
你可能会想到,那我永久保存吧。这样确实能解决问题,但显然服务器承载不了这么做,即使再微小的数据量,在时间的累加下,也总一天会超出服务器能够承载的上限。
5. 方案再再再升级:追加 timestamp 时间戳
我们可以再追加一个 timestamp 时间戳参数,将请求的有效性限定在一个有限时间范围内,例如 15分钟。
首先,在 A 系统追加 timestamp 参数:
public String join() {
// 参加完活动后,发送余额
Long userId = 1L;
Long money = 100L;
Long timestamp = System.currentTimeMillis();
String nonce = SaFoxUtil.getRandomString(32); // 随机32位字符串
String secretKey = "×××××××××××";
// 计算 sign
String sign = md5("money=" + money + "&nonce=" + nonce + "×tamp=" + timestamp + "&userId=" + userId + "&key=" + secretKey);
Map<String, Object> params = new HashMap<>();
params.put("userId", userId);
params.put("money", money);
params.put("nonce", nonce);
params.put("timestamp", timestamp);
params.put("sign", sign);
String url = "http://localhost:8079/sign/addMoney";
String result = HttpUtil.post(url, params);
return "join";
}
在 B 系统检测这个 timestamp 是否超出了允许的范围
public String addMoney(Long userId, Long money, Long timestamp, String nonce,String sign) {
// 1、检查 timestamp 是否超出允许的范围(此处假定最大允许15分钟差距)
long timestampDisparity = System.currentTimeMillis() - timestamp; // 实际的时间差
if(timestampDisparity > 1000 * 60 * 15) {
return "timestamp 时间差超出允许的范围,请求无效";
}
// 检查此 nonce 是否已被使用过了
if (Objects.nonNull(CacheUtil.get("nonce_" + nonce))) {
return "此 nonce 已被使用过了,请求无效";
}
// 在 B 系统,使用同样的算法、同样的密钥,计算出 sign2,与传入的 sign 进行比对
String sign2 = md5("money=" + money + "&nonce=" + nonce + "&userId=" + userId + "&key=" + secretKey);
if (!sign2.equals(sign)) {
return "无效 sign,无法响应请求";
}
// 将 nonce 记入缓存,ttl 有效期和 allowDisparity 允许时间差一致
CacheUtil.set("nonce_" + nonce, "1", 1000 * 60 * 15);
// TODO 处理业务...
return "ADD SUCCESS";
}
至此,抓包者:
- 如果在 15 分钟内重放攻击,nonce 参数不答应:缓存中可以查出 nonce 值,直接拒绝响应请求。
- 如果在 15 分钟后重放攻击,timestamp 参数不答应:超出了允许的 timestamp 时间差,直接拒绝响应请求。
6. 服务器的时钟差异造成安全问题
以上的代码,均假设 A 系统服务器与 B 系统服务器的时钟一致,才可以正常完成安全校验,但在实际的开发场景中,有些服务器会存在时钟不准确的问题。
假设 A 服务器与 B 服务器的时钟差异为 10 分钟,即:在 A 服务器为 8:00 的时候,B 服务器为 7:50。
- A 系统发起请求,其生成的时间戳也是代表 8:00。
- B 系统接受到请求后,完成业务处理,此时 nonce 的 ttl 为 15分钟,到期时间为 7:50 + 15分 = 8:05。
- 8.05 后,nonce 缓存消失,抓包者重放请求攻击:
* timestamp 校验通过:因为时间戳差距仅有 8.05 - 8.00 = 5分钟,小于 15 分钟,校验通过。
* -nonce 校验通过:因为此时 nonce 缓存已经消失,可以通过校验。
* sign 校验通过:因为这本来就是由 A 系统构建的一个合法签名。
* 攻击完成。
要解决上述问题,有两种方案:
- 方案一:修改服务器时钟,使两个服务器时钟保持一致。
- 方案二:在代码层面兼容时钟不一致的场景。
要采用方案一的同学可自行搜索一下同步时钟的方法,在此暂不赘述,此处详细阐述一下方案二。
我们只需简单修改一下,B 系统校验参数的代码即可:
public String addMoney(Long userId, Long money, Long timestamp, String nonce,String sign) {
// 1、检查 timestamp 是否超出允许的范围 (重点一:此处需要取绝对值)
long timestampDisparity = Math.abs(System.currentTimeMillis() - timestamp);
if(timestampDisparity > 1000 * 60 * 15) {
return "timestamp 时间差超出允许的范围,请求无效";
}
// 检查此 nonce 是否已被使用过了
if (Objects.nonNull(CacheUtil.get("nonce_" + nonce))) {
return "此 nonce 已被使用过了,请求无效";
}
// 在 B 系统,使用同样的算法、同样的密钥,计算出 sign2,与传入的 sign 进行比对
String sign2 = md5("money=" + money + "&nonce=" + nonce + "&userId=" + userId + "&key=" + secretKey);
if (!sign2.equals(sign)) {
return "无效 sign,无法响应请求";
}
// 将 nonce 记入缓存,防止重复使用(重点二:此处需要将 ttl 设定为允许 timestamp 时间差的值 x 2 )
CacheUtil.set("nonce_" + nonce, "1", (1000 * 60 * 15) * 2;
// TODO 处理业务...
return "ADD SUCCESS";
}
8. 使用 Sa-Token 框架完成 API 参数签名
接下来步入正题,使用 Sa-Token 内置的 sign 模块,方便的完成 API 签名创建、校验等步骤:
- 不限制请求的参数数量,方便组织业务需求代码。
- 自动补全 nonce、timestamp 参数,省时省力。
- 自动构建签名,并序列化参数为字符串。
- 一句代码完成 nonce、timestamp、sign 的校验,防伪造请求调用、防参数篡改、防重放攻击。
8.1 引入依赖
api-sign 模块已内嵌到核心包,只需要引入 sa-token 本身依赖即可:(请求发起端和接收端都需要引入)
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>1.35.0.RC</version>
</dependency>
8.2 配置密钥
请求发起端和接收端需要配置一个相同的秘钥,在 application.yml 中配置:
sa-token:
sign:
# API 接口签名秘钥 (随便乱摁几个字母即可)
secret-key: kQwIOrYvnXmSDkwEiFngrKidMcdrgKor
8.3 请求发起端构建签名
public String join() {
// 参加完活动后,发送余额
Long userId = 1L;
Long money = 100L;
Map<String, Object> params = new HashMap<>();
params.put("userId", userId);
params.put("money", money);
SaSignUtil.addSignParamsAndJoin(params);
String url = "http://localhost:8079/sign/addMoney";
return HttpUtil.post(url, params);
}
8.4 请求接受端校验签名
public String addMoney(Long userId, Long money) {
// 1、校验请求中的签名
SaSignUtil.checkRequest(SaHolder.getRequest());
// 2、校验通过,处理业务
System.out.println("userId=" + userId);
System.out.println("money=" + money);
return "ADD SUCCESS";
}
如上代码便可简单方便的完成 API 接口参数签名校验,当请求端的秘钥不对,或者请求参数被篡改、请求被重放时,均无法通过
SaSignUtil.checkRequest 校验
8.5 原理分析
8.5.1 构建签名
SaSignUtil#addSignParamsAndJoin(params);
:
public static String addSignParamsAndJoin(Map<String, Object> paramsMap) {
return SaManager.getSaSignTemplate().addSignParamsAndJoin(paramsMap);
}
会调用 SaSignTemplate
类中的方法
SaSignTemplate#addSignParamsAndJoin() 方法
public String addSignParamsAndJoin(Map<String, Object> paramsMap) {
// 1.添加参数:timestamp、nonce、sign
paramsMap = this.addSignParams(paramsMap);
// 2.将 map 使用 & 转化为String
return this.joinParams(paramsMap);
}
这个方法有两个逻辑:
- 添加参数:timestamp、nonce、sign
- 将 map 使用 & 转化为String
SaSignTemplate#addSignParams() 方法
public Map<String, Object> addSignParams(Map<String, Object> paramsMap) {
paramsMap.put(timestamp, String.valueOf(System.currentTimeMillis()));
paramsMap.put(nonce, SaFoxUtil.getRandomString(32));
paramsMap.put(sign, this.createSign(paramsMap));
return paramsMap;
}
SaSignTemplate#createSign() 方法
:生成签名
public String createSign(Map<String, ?> paramsMap) {
String secretKey = this.getSecretKey();
SaSignException.throwByNull(secretKey, "参与参数签名的秘钥不可为空", 12201);
if (((Map)paramsMap).containsKey(sign)) {
paramsMap = new TreeMap((Map)paramsMap);
((Map)paramsMap).remove(sign);
}
// 按照数据字典进行排序,并将 map 使用 & 转化为String
String paramsStr = this.joinParamsDictSort((Map)paramsMap);
String fullStr = paramsStr + "&" + key + "=" + secretKey;
// md5
return this.abstractStr(fullStr);
}
public String abstractStr(String fullStr) {
return SaSecureUtil.md5(fullStr);
}
这个方法有两个逻辑:
- 按照数据字典进行排序,并将 map 使用 & 转化为String
- 使用 md5 摘要算法
8.5.2 验证签名
SaSignUtil.checkRequest(SaHolder.getRequest());
:
public static void checkRequest(SaRequest request) {
SaManager.getSaSignTemplate().checkRequest(request);
}
还是会调用 SaSignTemplate
类中的方法
SaSignTemplate#checkParamMap() 方法
:校验请求参数
public void checkRequest(SaRequest request) {
this.checkParamMap(request.getParamMap());
}
public void checkParamMap(Map<String, String> paramMap) {
String timestampValue = (String)paramMap.get(timestamp);
String nonceValue = (String)paramMap.get(nonce);
String signValue = (String)paramMap.get(sign);
// 1.校验时间戳
this.checkTimestamp(Long.parseLong(timestampValue));
// 2.校验随机数
if (this.getSignConfigOrGlobal().getIsCheckNonce()) {
this.checkNonce(nonceValue);
}
// 3.校验签名
this.checkSign(paramMap, signValue);
}
这个方法有三个逻辑:
- 校验时间戳:判断是否在时间差范围内
- 校验随机数:判断此随机数是否已使用
- 校验签名:判断原签名和现在生成的签名是否一致
SaSignTemplate#checkNonce() 方法:
校验随机数
public void checkNonce(String nonce) {
if (SaFoxUtil.isEmpty(nonce)) {
throw new SaSignException("nonce 为空,无效");
} else {
String key = this.splicingNonceSaveKey(nonce);
if (SaManager.getSaTokenDao().get(key) != null) {
throw new SaSignException("此 nonce 已被使用过,不可重复使用:" + nonce);
} else {
SaManager.getSaTokenDao().set(key, nonce, this.getSignConfigOrGlobal().getSaveNonceExpire() * 2L + 2L);
}
}
}
SaToken 存储
SaTokenDao
是存储接口,默认实现是用的是 SaTokenDaoDefaultImpl
。SaTokenDaoDefaultImpl
存储数据,主要是通过 ConcurrentHashMap
存放在本地内存中。
SaManager#getSaTokenDao() 方法:
public static SaTokenDao getSaTokenDao() {
if (saTokenDao == null) {
Class var0 = SaManager.class;
synchronized(SaManager.class) {
if (saTokenDao == null) {
setSaTokenDaoMethod(new SaTokenDaoDefaultImpl());
}
}
}
return saTokenDao;
}
SaTokenDaoDefaultImpl
:
public class SaTokenDaoDefaultImpl implements SaTokenDao {
// 数据集合
public Map<String, Object> dataMap = new ConcurrentHashMap();
// 过期时间集合 (单位: 毫秒) , 记录所有key的到期时间 [注意不是剩余存活时间]
public Map<String, Long> expireMap = new ConcurrentHashMap();
public Thread refreshThread;
public volatile boolean refreshFlag;
public SaTokenDaoDefaultImpl() {
// 定时清理过期数据
this.initRefreshThread();
}
public String get(String key) {
this.clearKeyByTimeout(key);
return (String)this.dataMap.get(key);
}
public void set(String key, String value, long timeout) {
if (timeout != 0L && timeout > -2L) {
this.dataMap.put(key, value);
this.expireMap.put(key, timeout == -1L ? -1L : System.currentTimeMillis() + timeout * 1000L);
}
}
public void initRefreshThread() {
if (SaManager.getConfig().getDataRefreshPeriod() > 0) {
this.refreshFlag = true;
this.refreshThread = new Thread(() -> {
while(true) {
try {
try {
if (!this.refreshFlag) {
return;
}
this.refreshDataMap();
} catch (Exception var2) {
var2.printStackTrace();
}
int dataRefreshPeriod = SaManager.getConfig().getDataRefreshPeriod();
if (dataRefreshPeriod <= 0) {
dataRefreshPeriod = 1;
}
Thread.sleep((long)dataRefreshPeriod * 1000L);
} catch (Exception var3) {
var3.printStackTrace();
}
}
});
this.refreshThread.start();
}
}
}
如果仅仅存放在本地内存中,涉及到多个项目,可能数据无法共享。
引入仓库 sa-token-dao-redis-jackson
<!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
<version>1.35.0.RC</version>
</dependency>
<!-- 提供Redis连接池 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
SaTokenDaoRedisJackson
使用 Redis 作为存储数据的地方
SaBeanInject#setSaTokenDao
,SaBeanInject
是自动配置的。当系统中存在 SaTokenDao
的 Bean
实例,则设置SaTokenDao
实例
public class SaBeanInject {
@Autowired(
required = false
)
public void setSaTokenDao(SaTokenDao saTokenDao) {
SaManager.setSaTokenDao(saTokenDao);
}
}
参考:
API 接口参数签名
【开源项目】使用Sa-
Token框架完成API参数签名
学习网络安全技术的方法无非三种:
第一种是报网络安全专业,现在叫网络空间安全专业,主要专业课程:程序设计、计算机组成原理原理、数据结构、操作系统原理、数据库系统、 计算机网络、人工智能、自然语言处理、社会计算、网络安全法律法规、网络安全、内容安全、数字取证、机器学习,多媒体技术,信息检索、舆情分析等。
第二种是自学,就是在网上找资源、找教程,或者是想办法认识一-些大佬,抱紧大腿,不过这种方法很耗时间,而且学习没有规划,可能很长一段时间感觉自己没有进步,容易劝退。
如果你对网络安全入门感兴趣,那么你需要的话可以点击这里👉网络安全重磅福利:入门&进阶全套282G学习资源包免费分享!
第三种就是去找培训。
接下来,我会教你零基础入门快速入门上手网络安全。
网络安全入门到底是先学编程还是先学计算机基础?这是一个争议比较大的问题,有的人会建议先学编程,而有的人会建议先学计算机基础,其实这都是要学的。而且这些对学习网络安全来说非常重要。但是对于完全零基础的人来说又或者急于转行的人来说,学习编程或者计算机基础对他们来说都有一定的难度,并且花费时间太长。
第一阶段:基础准备 4周~6周
这个阶段是所有准备进入安全行业必学的部分,俗话说:基础不劳,地动山摇
第二阶段:web渗透
学习基础 时间:1周 ~ 2周:
① 了解基本概念:(SQL注入、XSS、上传、CSRF、一句话木马、等)为之后的WEB渗透测试打下基础。
② 查看一些论坛的一些Web渗透,学一学案例的思路,每一个站点都不一样,所以思路是主要的。
③ 学会提问的艺术,如果遇到不懂得要善于提问。
配置渗透环境 时间:3周 ~ 4周:
① 了解渗透测试常用的工具,例如(AWVS、SQLMAP、NMAP、BURP、中国菜刀等)。
② 下载这些工具无后门版本并且安装到计算机上。
③ 了解这些工具的使用场景,懂得基本的使用,推荐在Google上查找。
渗透实战操作 时间:约6周:
① 在网上搜索渗透实战案例,深入了解SQL注入、文件上传、解析漏洞等在实战中的使用。
② 自己搭建漏洞环境测试,推荐DWVA,SQLi-labs,Upload-labs,bWAPP。
③ 懂得渗透测试的阶段,每一个阶段需要做那些动作:例如PTES渗透测试执行标准。
④ 深入研究手工SQL注入,寻找绕过waf的方法,制作自己的脚本。
⑤ 研究文件上传的原理,如何进行截断、双重后缀欺骗(IIS、PHP)、解析漏洞利用(IIS、Nignix、Apache)等,参照:上传攻击框架。
⑥ 了解XSS形成原理和种类,在DWVA中进行实践,使用一个含有XSS漏洞的cms,安装安全狗等进行测试。
⑦ 了解一句话木马,并尝试编写过狗一句话。
⑧ 研究在Windows和Linux下的提升权限,Google关键词:提权
以上就是入门阶段
第三阶段:进阶
已经入门并且找到工作之后又该怎么进阶?详情看下图
给新手小白的入门建议:
新手入门学习最好还是从视频入手进行学习,视频的浅显易懂相比起晦涩的文字而言更容易吸收,这里我给大家准备了一套网络安全从入门到精通的视频学习资料包免费领取哦!
如果你对网络安全入门感兴趣,那么你需要的话可以点击这里👉网络安全重磅福利:入门&进阶全套282G学习资源包免费分享!