聊一聊服务中的签名验签、加密解密、重放限流、非法请求等安全策略

1,038 阅读11分钟

为了方便的演示,本文中的所有代码均为伪代码,如需具体的实现可以自行 AI翻译

一、背景说明

在实际开发中,我们遇到很多安全性相关的问题,例如:防止数据篡改、防止重复提交、防止过度请求、防止数据窃取等,本文将通过 Q&A 配合伪代码的方式来介绍如何实现这些功能。

二、基础概念

2.1 签名验签

签名验签是防止数据篡改的一种方式,通过计算签名,然后与客户端提交的签名进行比较,如果一致,则说明数据没有被篡改。

签名通常使用一些 hash 摘要算法,例如 md5 sha1 sha256 等。(一般认为,这些算法都不算加密算法,因为都是不可逆的,没有解密就不能称之为加密。)

当然,大多数人都说过不再推荐 md5 sha1 算法,因为碰撞攻击的速度已经很快、成本已经很低。

签名涉及到的一些场景:

2.1.1 交互数据签名

交互数据签名常用在网络请求交互数据过程中,通过对 请求参数 的签名,来确保服务器收到的数据是可信的、不被篡改的。

2.1.2 文件签名

文件签名常用在文件来源可信、文件完整性校验等。例如很多下载站都会提供原始文件的各种 hash 值,本地下载之后可验算 hash 来确保文件正确。

文件签名还用在网盘、文件上传等场景。例如网盘上传文件前会计算文件签名,如签名和服务端已有签名的文件一致,则被认为即将上传的文件已经曾经被上传过了,服务端则可能直接复制远端的文件或者直接修改为同一文件引用地址来让客户端实现 秒上传 的状态。

2.1.3 身份签名

身份签名与交互数据签名一致,只是被签名数据中往往包含了用户的账号信息,被签名的数据则可作为用户的身份令牌来进行身份验证。比较常见的场景例如 JWT

2.2 加密与解密

加密与解密是防止数据窃取的一种方式,通过加密算法,将数据进行加密,然后通过解密算法,将加密后的数据进行解密,从而实现数据安全。

加解密有三种类型:

2.2.1 对称加密

所谓对称,即加解密使用的密钥是一致的。常见的有 DES AES 等。这种加密算法效率高,但密钥必须保存在加解密两端,一旦密钥泄露,就会导致整个加密系统被破解。

2.2.2 非对称加密

非对称加解密,则加解密两端的密钥不一致且成对,知晓任何一方的密钥,无法推断出另一端的密钥。使用一方密钥加密后,只能使用另一方的密钥解密。常见的有 RSA ECC 等。

一般加解密都面临可信方(我方)和不可信方(对方),所以我方一般持有私钥,而对方持有公钥。

非对称加密对于性能消耗要高于对称加密,但安全性更高。

2.2.3 混合加密

混合加密是指加密过程中,既使用了对称加密,也使用了非对称加密。例如 tlsssl 等。

对,你没听错,https 并不是非对称加密,而是 非对称加密和对称加密 混合的加密方式。

2.3 重放攻击

重放攻击是指在网络通信过程中,攻击者通过拦截请求后再次多次发起请求。例如你在给张三转账,转账成功了,但你的数据包被拦截后复制了一份,攻击者可以再次发起请求,来实现多次给张三继续转账。

三、Q&A

3.1. 防止数据篡改

数据篡改是指在数据发送方和接收方之间,数据被篡改的情况。例如,你正常发起提现,然而中间人抓包拦截了你的请求,把收款账号改为了他的,然后再提交数据给服务器,此时如果服务器没有处理数据篡改,就会导致提现到了第三方账号中。

通过对所有请求参数进行签名,然后服务器端进行验签,如果验签通过,则继续处理业务,否则返回错误。

示例代码:

// client
const postData = {
  account: '123456',
  amount: '100'
}
const str4Sign = "account=" + postData.account + "&amount=" + postData.amount
const sign = md5(str4Sign)
postData.sign = sign
doPost(url, postData)


// server
const postData = getPostData()
const str4Sign = "account=" + postData.account + "&amount=" + postData.amount
const sign = md5(str4Sign)
if (sign !== postData.sign) {
  return '签名错误'
}
// 执行转账
// ...

上述过程中,服务端和客户端约定了签名的规则,即 account=123456&amount=100,然后对这个字符串进行 md5 签名,得到一个签名值,最后在客户端将签名值添加到请求参数中,服务端在接收到请求参数后,通过相同的规则对请求参数进行签名,并与客户端提交的签名值进行比较,如果一致,则认为请求参数没有被篡改。

请注意,需要防止篡改的数据,一定要作为 签名因子 出现在被签名的字符串中。

3.2. 防止重放攻击

防止重放攻击只需要在签名与验签的过程中加入请求随机数,再缓存随机数一段时间即可实现。

示例代码

// client
const postData = {
  account: '123456',
  amount: '100',
  nonce: '123456789' // 请求随机数
}
const str4Sign = "account=" + postData.account + "&amount=" + postData.amount + "&nonce=" + postData.nonce
const sign = md5(str4Sign)
postData.sign = sign
doPost(url, postData)

// server
const postData = getPostData()
const str4Sign = "account=" + postData.account + "&amount=" + postData.amount + "&nonce=" + postData.nonce
const sign = md5(str4Sign)
if (sign !== postData.sign) {
  return '签名错误'
}

if (cache.get(postData.nonce)) {
  return '请求重复'
}

// 缓存请求随机数,并设置过期时间
cache.set(postData.nonce, 1)

如上请求,可以保证任何请求都不会被重放,但因为随机数由客户端产生,后续正常请求可能会因为产生已经存在的随机数而被拦截到。

3.3. 防止限定时间重放

为了规避防止重放中出现的随机数问题,我们可以在签名数据中加入时间戳,并为缓存设置过期时间来调整。

  • 示例代码
// client
const timestamp = Date.now().valueOf()
const postData = {
  account: '123456',
  amount: '100',
  nonce: '123456789',
  timestamp: timestamp
}
const str4Sign = "account=" + postData.account + "&amount=" + postData.amount + "&nonce=" + postData.nonce + "&timestamp=" + postData.timestamp
const sign = md5(str4Sign)
postData.sign = sign
doPost(url, postData)

// server
const postData = getPostData()
const str4Sign = "account=" + postData.account + "&amount=" + postData.amount + "&nonce=" + postData.nonce + "&timestamp=" + postData.timestamp
const sign = md5(str4Sign)
if (sign !== postData.sign) {
  return '签名错误'
}
const timestamp = postData.timestamp
const currentTimestamp = Date.now().valueOf()
const timeRange = 60 * 1000
if (timestamp < currentTimestamp - timeRange || timestamp > currentTimestamp + timeRange) {
  // 客户端时间和服务端时间相差60s以上,则拒绝请求
  return '请求时间异常'
}
if (cache.get(postData.nonce)) {
  return '请求重复'
}
// 60s 后,相同随机数产生后依然可以发起请求
cache.set(postData.nonce, 1, timeRange)

如上,我们通过调整时间限制范围,可以降低产生重复随机串后无法正常请求的问题。

3.4. 服务端之间的API调用安全

请注意,之前的三种方式都是明文数据传输的,只是加入了一些签名验签机制来保证数据不被篡改和重放,但因为数据是明文,可能还会存在一些数据窃取的风险。

在服务端之间进行API调用时,我们往往使用一些对称或者非对称加解密方式来将敏感数据进行加密,保证在传输过程中攻击者拿不到我们的敏感数据,比如身份证、银行卡号等。

3.4.1 使用对称加密:

// 加密方
const crypto = require('crypto')
// 双方约定的密钥
const key = '1234567890123456' 
// 双方约定的初始化向量
const iv = '1234567890123456'
// 约定加密方式和填充方式
const mode = CryptoJS.mode.CBC
const padding = CryptoJS.pad.Pkcs7

// 待加密数据
const data = '1234567890123456'

const encryptedData =  CryptoJS.AES.encrypt(data,key, {
  iv, mode, padding
})


// 解密方
const crypto = require('crypto')
// 双方约定的密钥
const key = '1234567890123456'
// 双方约定的初始化向量
const iv = '1234567890123456'
// 双方约定的初始化向量
const iv = '1234567890123456'
// 约定加密方式和填充方式
const mode = CryptoJS.mode.CBC
const padding = CryptoJS.pad.Pkcs7
// 待解密数据
const encryptedData = 'xxxxxxxxxxxxxxxx'
const decryptedData = CryptoJS.AES.decrypt(encryptedData, key, {
  iv, mode, padding
})

对称加密存在密钥泄露的问题,只要任何一方泄露了密钥,就可以对加密数据进行解密,而且双方都需要进行重新密钥协商。

3.4.2 使用非对称加密:

使用非对称加密,泄露公钥后,任何人在没有私钥的情况下依然无法解密出数据。

如果配合 x509 证书方式,当证书持有方(公钥方)泄露之后,使用签发证书重新为泄露方签署新证书(公钥)即可保证后续的数据安全。

另外:

使用公钥加密后,只能使用私钥解密;使用私钥加密后,也只能使用公钥解密。

// 公钥加密方
const publicKey = ""
const data = "1234567890123456"
const encryptedData = publicEncrypt(publicKey, data)

// 私钥解密方
const privateKey = ""
const decryptedData = privateDecrypt(privateKey, encryptedData)

// 私钥加密方
const privateKey = ""
const data = "1234567890123456"
const encryptedData = privateEncrypt(privateKey, data)

// 公钥解密方
const publicKey = ""
const decryptedData = publicDecrypt(publicKey, encryptedData)

x509 证书方式中可包含签发者、证书持有者、证书有效期、证书指纹等信息,如果证书持有者的证书泄露,则签发方可以标记该证书被吊销,则拿到泄露的证书公钥对数据进行加密后,服务端将不信任该加密数据的内容。

https 则是使用这个机制,加上一些权威机构认证,来确保下发给浏览器的证书是被信任的、有效的,再使用混合加密来实现数据传输的安全。

3.5 防止过度请求

过度请求一般在爬虫或者用户过于频繁的刷新某些页面时发生,一般会使用一些客户端防抖、服务端限流等方式来维持服务器的良好性能。

我们可以使用 IP账号设备UA浏览器指纹 等等来识别不同的请求,然后根据不同的请求类型进行拦截。

可以使用类似 Nginx 的流量拦截或者是其他网关侧的拦截,也可以在业务代码中进行拦截。

比如某些指纹下指定时间段内请求次数超过某个阈值之后,拒绝响应并拉黑请求指纹。

3.6 非法请求拦截

非法请求的类型就比较多了,常见的有下面这些:

3.6.1 爬虫请求

爬虫请求往往是根据正常请求来进行分析后,再通过程序来模拟人为请求。非法爬虫请求的拦截方式一般有两种:

  1. 做反爬策略拦截
  2. 发律师函(好尴尬啊,曾经就收到了一个)

反爬策略拦截,就是通过反向分析爬虫请求的特征来写一些规则拦截,常见的有一些 各种验证码 用户请求路线 等行为分析方式。

反爬是一个很大的领域,后续有机会的话写新一篇来继续聊。

3.6.2 请求注入

请求注入是指在请求中插入一些恶意的代码,从而达到执行恶意代码的目的。常见的有 XSSSQL注入 等。

可以通过一些 WAF 防火墙,或者是对请求参数进行过滤、正则匹配等方式来防止。

3.6.3 流量攻击

流量攻击也被成为 最恶心的攻击,因为它们不会对服务器造成任何影响,但是却会消耗大量带宽,导致服务器无法正常提供服务。

流量攻击的预防方式有很多,比如:隐藏服务真实IP地址、使用CDN、使用高防、不定时更换接入网关等。当然,除了这些方式,下面还有一些备选的处理方式:

  • 拿钱怼高防,看谁更厉害
  • 报警。
  • 摆烂。

四、总结

这篇文章简单聊了聊日常开发中碰到的一些安全问题和简单的处理方式,如果有什么问题,欢迎留言交流。

后续会继续在 Web安全 这个话题上做一些分享,欢迎关注~

That's all!