说到 Web 安全,我们前端可能接触较多的是 XSS 和 CSRF。工作原因,在所负责的内部服务中遭遇了SSRF 的困扰,在此记录一下学习过程及解决方案。SSRF(Server-Side Request Forgery),即服务端请求伪造,是一种由攻击者构造形成由服务端发起请求的一个安全漏洞。一般情况下,SSRF 攻击的目标是从外网无法访问的内部系统。
SSRF 形成的原因大都是由于服务端提供了从其他服务器应用获取数据的功能且没有对目标地址做过滤与限制。比如从指定 URL 地址获取网页文本内容,加载指定地址的图片,下载等等。攻击者可根据程序流程,使用应用所在服务器发出攻击者想发出的 http 请求,利用该漏洞来探测生产网中的服务,可以将攻击者直接代理进内网中,可以让攻击者绕过网络访问控制,可以下载未授权的文件,可以直接访问内网,甚至能够获取服务器凭证。
笔者负责的内部 web 应用中有一个下载文件的接口 /download
,其接受一个 url 参数,指向需要下载的文件地址,应用向该地址发起请求,下载文件至应用所在服务器,然后作后续处理。问题便来了,应用所在服务器在这里成了跳板机,攻击者利用这个接口相当于取得了内网权限,能够进行不少具有危害的操作。
SSRF 带来的危害有:
通用的解决方案有:
1.过滤返回信息。验证远程服务器对请求的响应是比较容易的方法。如果 web 应用是去获取某一种类型的文件,那么在把返回结果展示给用户之前先验证返回的信息是否符合标准;
2.统一错误信息,避免用户可以根据错误信息来判断远端服务器的端口状态;
3.限制请求的端口为 http 常用的端口,比如 80, 443, 8080, 8090;
4.白名单内网 ip。避免应用被用来获取获取内网数据,攻击内网;
5.禁用不需要的协议。仅仅允许 http 和 https 请求。可以防止类似于file:///,gopher://,ftp:// 等引起的问题。
由于笔者的应用 /download
接口请求的文件地址比较固定,因此采用了白名单 IP 的方式。当然,笔者也学习了一下更加全面的解决方案,下面给出安全部门同事的思路:
1.协议限制(默认允许协议为 HTTP、HTTPS)、30x跳转(默认不允许 30x 跳转)、统一错误信息(默认不统一,统一错误信息避免恶意攻击通过错误信息判断)2.IP地址判断:* 禁止访问 0.0.0.0/8,169.254.0.0/16,127.0.0.0/8 和 240.0.0.0/4 等保留网段* 若 IP 为 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 私有网段,请求该 IP 地址并判断响应 contents-type
是否为 application/json
3.解决 URL 获取器和 URL 解析器不一致的方法为:解析 URL 后去除 RFC3986 中 user、pass 并重新组合 URL然后是按照以上思路实现的 Node.js 版本的处理 SSRF 漏洞的主要函数的代码:
const dns = require('dns')
const parse = require('url-parse')
const ip = require('ip')
const isReservedIp = require('martian-cidr').default
const protocolAndDomainRE = /^(?:https?:)?\/\/(\S+)$/
const localhostDomainRE = /^localhost[\:?\d]*(?:[^\:?\d]\S*)?$/
const nonLocalhostDomainRE = /^[^\s\.]+\.\S{2,}$/
/**
* 检查链接是否合法
* 仅支持 http/https 协议
* @param {string} string
* @returns {boolean}
*/
function isValidLink (string) {if (typeof string !== 'string') {return false}var match = string.match(protocolAndDomainRE)if (!match) {return false}var everythingAfterProtocol = match[1]if (!everythingAfterProtocol) {return false}if (localhostDomainRE.test(everythingAfterProtocol) ||nonLocalhostDomainRE.test(everythingAfterProtocol)) {return true}return false
}
/**
* @param {string} uri
* @return string
* host 解析为 ip 地址
* 处理 SSRF 绕过:URL 解析器和 URL 获取器之间的不一致性
*
*/
async function filterIp(uri) {try {if (isValidLink(uri)) {const renwerurl = renewUrl(uri)const parseurl = parse(renwerurl)const host = await getHostByName(parseurl.host)const validataResult = isValidataIp(host)if(!validataResult) {return false} else {return renwerurl}} else {return false}} catch (e) {console.log(e)}
}
/**
* 根据域名获取 IP 地址
* @param {string} domain
*/
function getHostByName (domain) {return new Promise((resolve, reject) => {dns.lookup(domain, (err, address, family) => {if(err) {reject(err)}resolve(address)})})
}
/**
* @param {string} host
* @return {array} 包含 host、状态码
*
* 验证 host ip 是否合法
* 返回值 array(host, value)
* 禁止访问 0.0.0.0/8,169.254.0.0/16,127.0.0.0/8,240.0.0.0/4 保留网段
* 若访问 10.0.0.0/8,172.16.0.0/12,192,168.0.0/16 私有网段,标记为 PrivIp 并返回
*/
function isValidataIp (host) {if ((ip.isV4Format(host) || ip.isV6Format(host)) && !isReservedIp(host)) {if (ip.isPrivate(host)) {return [host, 'PrivIp']} else {return [host, 'WebIp']}} else {return false}
}
/**
* @param {string} uri
* @return {string} validateuri
* 解析并重新组合 url,其中禁止'user' 'pass'组合
*/
function renewUrl(uri) {const uriObj = parse(uri)let validateuri = `${uriObj.protocol}//${uriObj.host}`if (uriObj.port) {validateuri += `:${uriObj.port}`}if (uriObj.pathname) {validateuri += `${uriObj.pathname}`}if (uriObj.query) {validateuri += `?${uriObj.query}`}if (uriObj.hash) {validateuri += `#${uriObj.hash}`}return validateuri
}
对于最主要的可能出现漏洞的接口处理函数,由于各逻辑不同,这里就不给出具体实现。但是只要按照上面提出的规避 SSRF 漏洞的原则,结合上述几个函数,就能大致完成。
最后,一句话总结:永远不要相信用户的输入!