Update: 評論區有同學提出通過域名獲取 IP 地址時可能遭遇攻擊,感謝提醒。本人非安全專業相關人士,瞭解不多,實在慚愧。
說到 Web 安全,我們前端可能接觸較多的是 XSS 和 CSRF。工作原因,在所負責的內部服務中遭遇了SSRF 的困擾,在此記錄一下學習過程及解決方案。SSRF(Server-Side Request Forgery),即服務端請求偽造,是一種由攻擊者構造形成由服務端發起請求的一個安全漏洞。一般情況下,SSRF 攻擊的目標是從外網無法訪問的內部系統。
SSRF 形成的原因大都是由於服務端提供了從其他伺服器應用獲取資料的功能且沒有對目標地址做過濾與限制。比如從指定 URL 地址獲取網頁文字內容,載入指定地址的圖片,下載等等。攻擊者可根據程式流程,使用應用所在伺服器發出攻擊者想發出的 http 請求,利用該漏洞來探測生產網中的服務,可以將攻擊者直接代理進內網中,可以讓攻擊者繞過網路訪問控制,可以下載未授權的檔案,可以直接訪問內網,甚至能夠獲取伺服器憑證。
筆者負責的內部 web 應用中有一個下載檔案的介面 /download
,其接受一個 url 引數,指向需要下載的檔案地址,應用向該地址發起請求,下載檔案至應用所在伺服器,然後作後續處理。問題便來了,應用所在伺服器在這裡成了跳板機,攻擊者利用這個介面相當於取得了內網許可權,能夠進行不少具有危害的操作。
SSRF 帶來的危害有:
- 可以對外網、伺服器所在內網、本地進行埠掃描,獲取一些服務的 banner 資訊;
- 攻擊執行在內網或本地的應用程式(比如溢位);
- 對內網 web 應用進行指紋識別,通過訪問預設檔案實現;
- 攻擊內外網的 web 應用,主要是使用 get 引數就可以實現的攻擊(比如 struts2,sqli 等);
- 利用 file 協議讀取本地檔案等。
通用的解決方案有:
- 過濾返回資訊。驗證遠端伺服器對請求的響應是比較容易的方法。如果 web 應用是去獲取某一種型別的檔案,那麼在把返回結果展示給使用者之前先驗證返回的資訊是否符合標準;
- 統一錯誤資訊,避免使用者可以根據錯誤資訊來判斷遠端伺服器的埠狀態;
- 限制請求的埠為 http 常用的埠,比如 80, 443, 8080, 8090;
- 白名單內網 ip。避免應用被用來獲取獲取內網資料,攻擊內網;
- 禁用不需要的協議。僅僅允許 http 和 https 請求。可以防止類似於file:///,gopher://,ftp:// 等引起的問題。
由於筆者的應用 /download
介面請求的檔案地址比較固定,因此採用了白名單 IP 的方式。當然,筆者也學習了一下更加全面的解決方案,下面給出安全部門同事的思路:
-
協議限制(預設允許協議為 HTTP、HTTPS)、30x跳轉(預設不允許 30x 跳轉)、統一錯誤資訊(預設不統一,統一錯誤資訊避免惡意攻擊通過錯誤資訊判斷)
-
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
-
解決 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 漏洞的原則,結合上述幾個函式,就能大致完成。
最後,一句話總結:永遠不要相信使用者的輸入!
本文首發於我的部落格(點此檢視),歡迎關注