
在滲透測試和漏洞挖掘過程中我們常常會遇到部署了安全過濾器的應用它們旨在攔截惡意的SSRFServer-Side Request Forgery服務器端請求偽造攻擊。然而道高一尺魔高一丈攻擊者總能找到新的繞過方法。今天我們就來深入探討一種經典的繞過技術——雙重編碼。本文將從一個實戰場景出發詳細拆解雙重編碼繞過安全過濾器的原理、步驟、代碼實現以及防御思路無論你是安全研究員、開發工程師還是對Web安全感興趣的愛好者都能從中獲得一套完整的、可復現的攻防知識體系。1. SSRF漏洞與安全過濾器攻防背景在深入技術細節之前我們有必要先理解這場攻防對抗的雙方。1.1 SSRF漏洞的核心威脅SSRF即服務器端請求偽造是一種由攻擊者構造請求誘使服務器端應用向非預期的內部或外部地址發起請求的安全漏洞。其危害極大主要體現在攻擊內網服務利用存在漏洞的服務器作為跳板掃描或攻擊其所在內網中其他不可從外網直接訪問的服務如數據庫、Redis、管理后臺。讀取本地文件利用file://協議讀取服務器上的敏感文件如/etc/passwd, 應用配置文件。端口掃描探測內網或本機開放的服務端口。請求篡改與反射攻擊結合其他漏洞實現更復雜的攻擊鏈。一個典型的、存在漏洞的代碼示例如下Python Flask# vulnerable_app.py - 存在SSRF漏洞的示例 from flask import Flask, request import requests app Flask(__name__) app.route(/fetch) def fetch_url(): url request.args.get(url) # 用戶可控的輸入 if url: try: response requests.get(url, timeout5) return response.text[:500] # 返回前500個字符 except Exception as e: return fError fetching URL: {e} return Please provide a URL parameter. if __name__ __main__: app.run(debugTrue)攻擊者可以傳入http://internal-admin-panel.local/或file:///etc/passwd等惡意參數。1.2 安全過濾器的常見策略為了防御SSRF開發者通常會部署安全過濾器Security Filter或編寫校驗邏輯。常見的過濾策略包括協議黑/白名單只允許http://和https://或禁止file://、gopher://、dict://等危險協議。域名/IP黑名單禁止訪問內網IP段如127.0.0.1、192.168.0.0/16、10.0.0.0/8、172.16.0.0/12或本地回環地址。域名/IP白名單只允許訪問指定的、可信的外部域名。URL解析與規范化對輸入的URL進行解析提取出host、port、scheme然后進行規則匹配。一個簡單的過濾器可能長這樣# simple_filter.py - 一個簡單的SSRF過濾器 from urllib.parse import urlparse import ipaddress import re def is_ssrf_safe(url): 一個存在缺陷的SSRF安全檢查函數 try: parsed urlparse(url) hostname parsed.hostname # 策略1禁止file等協議 if parsed.scheme not in [http, https]: return False, fDangerous scheme: {parsed.scheme} # 策略2禁止IPv4格式的本地或內網地址 if hostname: # 檢查是否是IP地址 try: ip ipaddress.ip_address(hostname) if ip.is_private or ip.is_loopback: return False, fAccess to private/loopback IP is forbidden: {hostname} except ValueError: # 不是IP地址可能是域名這里簡單放過實際應做DNS解析檢查 pass # 策略3簡單正則匹配localhost等域名容易被繞過 forbidden_domains [localhost, 127.0.0.1, 0.0.0.0, ::1] for fd in forbidden_domains: if fd in hostname: return False, fForbidden domain keyword found: {fd} return True, URL seems safe except Exception as e: return False, fURL parsing error: {e}2. 編碼與雙重編碼繞過原理剖析當直接輸入惡意URL被攔截時攻擊者會嘗試對URL進行“變形”以期繞過過濾器的檢測邏輯。編碼就是最常用的變形手段。2.1 單次編碼的局限性URL編碼Percent-encoding是將URL中不允許或具有特殊意義的字符轉換為%后跟兩位十六進制數的形式。例如點號.編碼后是%2e。攻擊者可能會嘗試將http://127.0.0.1編碼為http://127%2e0%2e0%2e1。如果過濾器的檢查邏輯是先解碼再檢查那么它能夠正確識別出127.0.0.1并攔截。如果過濾器只檢查原始輸入字符串那么它可能不認識%2e就是點號從而放行。但現代稍完善一點的過濾器都會包含解碼步驟。2.2 雙重編碼的生效場景雙重編碼Double Encoding的精髓在于利用應用程序或過濾器鏈中多次、不一致的解碼操作。其攻擊路徑通常如下攻擊者輸入雙重編碼的Payload例如將127.0.0.1先編碼一次得到127.0.0.1點號變%2e再將整個字符串編碼第二次得到127%2e0%2e0%2e1第一次的%被編碼為%25。安全過濾器解碼一次過濾器收到127%2e0%2e0%2e1進行了一次URL解碼得到127.0.0.1。此時它看到的仍然是編碼后的形式%2e如果它的檢查邏輯是簡單的字符串匹配尋找127.0.0.1或localhost它可能無法識別%2e就是點號從而錯誤地判斷該URL是安全的。后端業務邏輯再次解碼當這個被過濾器“放行”的URL傳遞到后端真正發起請求的函數如requests.get(),curl時該函數通常會自動地、再次進行URL解碼。于是127.0.0.1被解碼為127.0.0.1。攻擊成功后端最終向127.0.0.1發起了請求SSRF攻擊達成。關鍵點漏洞產生的核心是安全檢查環節的解碼次數與實際請求發起環節的解碼次數不一致。過濾器解了一層沒認出來請求庫又解了一層還原了原貌。3. 環境準備與靶場搭建為了清晰地復現和演示我們需要一個可控的環境。我們將使用 Docker 快速搭建一個包含漏洞和過濾器的測試應用。3.1 環境與工具清單操作系統Linux (Ubuntu 20.04) / macOS / Windows (WSL2推薦)Docker Docker Compose用于容器化部署靶場和內部服務。Python 3.8用于編寫漏洞代碼、過濾器及攻擊腳本。瀏覽器或curl命令用于發送測試請求。Burp Suite 或 Postman可選用于更靈活地攔截和修改請求。3.2 搭建靶場環境我們創建一個項目目錄ssrf-double-encoding-demo并建立如下結構ssrf-double-encoding-demo/ ├── docker-compose.yml ├── vulnerable_app/ │ ├── app.py # 存在漏洞的主應用 │ ├── filter.py # 有缺陷的安全過濾器 │ └── requirements.txt └── internal_service/ └── docker-compose.yml # 模擬內網服務1. 模擬內網服務在internal_service目錄下創建一個簡單的 HTTP 服務來代表內網應用。# internal_service/docker-compose.yml version: 3.8 services: internal-admin: image: nginx:alpine container_name: internal_admin_panel ports: - 8081:80 # 映射到主機8081端口僅用于演示實際內網不映射 volumes: - ./admin.html:/usr/share/nginx/html/index.html創建admin.html文件!-- internal_service/admin.html -- !DOCTYPE html html headtitleInternal Admin Panel/title/head body h1 INTERNAL ADMIN PANEL /h1 pThis page should never be accessible from the outside world!/p pSecret Flag: FLAG{SSRF_D0UBLE_3NC0DING_1S_FUN}/p /body /html2. 編寫有漏洞的應用和過濾器在vulnerable_app目錄下# vulnerable_app/requirements.txt flask2.3.3 requests2.31.0# vulnerable_app/filter.py from urllib.parse import urlparse, unquote import ipaddress import re def ssrf_filter(url): 存在缺陷的過濾器只進行一次URL解碼且使用簡單的字符串匹配。 # 模擬一次URL解碼這是過濾器的解碼操作 decoded_once unquote(url) print(f[FILTER] Input: {url}) print(f[FILTER] After first decode: {decoded_once}) parsed urlparse(decoded_once) # 注意這里解析的是解碼一次后的URL hostname parsed.hostname # 黑名單檢查 blacklist_ips [127.0.0.1, 0.0.0.0, localhost, 192.168., 10., 172.16.] for bip in blacklist_ips: if hostname and bip in hostname: print(f[FILTER] BLOCKED by blacklist: {bip} in {hostname}) return False, fBlacklisted pattern detected: {bip} print(f[FILTER] PASSED) return True, Passed filter# vulnerable_app/app.py from flask import Flask, request, jsonify import requests from filter import ssrf_filter app Flask(__name__) app.route(/api/fetch, methods[GET]) def fetch_url(): 存在SSRF漏洞的接口但前面加了一個有缺陷的過濾器。 url_to_fetch request.args.get(url) if not url_to_fetch: return jsonify({error: Missing URL parameter}), 400 # Step 1: 安全檢查 is_safe, msg ssrf_filter(url_to_fetch) if not is_safe: return jsonify({error: Security check failed, detail: msg}), 403 # Step 2: 發起請求 (這里會再次自動解碼) try: # requests.get() 會對URL進行自動解碼 print(f[APP] Making request to: {url_to_fetch}) response requests.get(url_to_fetch, timeout3) # 僅返回狀態碼和部分內容避免信息泄露過多 return jsonify({ status_code: response.status_code, content_preview: response.text[:200] }) except requests.exceptions.Timeout: return jsonify({error: Request timeout}), 504 except Exception as e: return jsonify({error: Failed to fetch URL, detail: str(e)}), 500 if __name__ __main__: app.run(host0.0.0.0, port5000, debugFalse) # 生產環境務必關閉debug3. 編寫主docker-compose.yml在項目根目錄# docker-compose.yml version: 3.8 services: vulnerable-app: build: ./vulnerable_app container_name: ssrf_vulnerable_app ports: - 5000:5000 networks: - internal-net - default depends_on: - internal-admin internal-admin: build: ./internal_service container_name: internal_admin_panel networks: - internal-net # 注意這個服務不映射端口到宿主機模擬純內網服務 networks: internal-net: driver: bridge4. 構建并運行在項目根目錄執行docker-compose up --build等待構建完成后訪問http://localhost:5000/api/fetch?urlhttp://example.com測試應用是否正常。4. 雙重編碼繞過實戰演示現在我們的靶場已經運行。vulnerable-app(端口5000) 可以訪問外網和internal-net網絡。internal-admin只存在于internal-net中宿主機無法直接訪問其80端口我們之前映射8081只是為了演示它存在。4.1 正常攻擊被攔截首先我們嘗試直接攻擊內網服務curl http://localhost:5000/api/fetch?urlhttp://internal-admin/或者使用瀏覽器訪問。應用會返回類似Security check failed的錯誤因為過濾器識別了internal-admin這個主機名在我們的簡單過濾器中它可能通過了但如果是IP黑名單則會被攔。讓我們測試一個更明確的IP地址。假設我們想訪問http://127.0.0.1:8081我們映射出來的那個管理頁面。實際上從容器內訪問127.0.0.1是指向容器自己而不是宿主機。為了演示我們讓應用訪問同一個網絡中的internal-admin服務其容器名可作為主機名即http://internal-admin。我們先試一個會被攔截的請求假設過濾器加強了# 假設過濾器現在能正確解析 internal-admin 并禁止 # 我們直接請求預期被攔截 curl -s http://localhost:5000/api/fetch?urlhttp://internal-admin | python3 -m json.tool觀察應用和過濾器的日志輸出應該能看到攔截信息。4.2 構造雙重編碼Payload我們的目標是訪問http://internal-admin。為了繞過我們對internal-admin這個主機名進行雙重編碼。第一步第一次編碼將internal-admin進行URL編碼。注意只有非字母數字字符需要編碼。連字符-在某些上下文中可以不編碼但編碼了更穩妥。我們編碼點號.雖然這里沒有和連字符-。 實際上internal-admin本身是合法的URL主機名無需編碼。關鍵在于過濾器可能會對“點號”或“特定模式”進行字符串匹配。為了演示我們假設過濾器愚蠢到會匹配字符串internal-admin。那么我們就編碼這個字符串里的字母不那解碼后就變了。更經典的例子是使用IP地址的十進制形式或八進制形式然后編碼點號。但我們的內網服務是域名。讓我們構造一個更通用的場景假設過濾器黑名單包含admin這個詞。我們想訪問http://secret-admin-panel.local。原始目標URL:http://secret-admin-panel.local第一次編碼編碼-和.--%2d.-%2e得到http://secret%2dadmin%2dpanel%2elocal第二次編碼對整個字符串的%符號進行編碼%-%25得到http://secret%252dadmin%252dpanel%252elocal回到我們的靶場我們的過濾器黑名單是[127.0.0.1, 0.0.0.0, localhost, 192.168., 10., 172.16.]并且它只做一次解碼。我們想訪問internal-admin它不在黑名單中。但假設我們想訪問127.0.0.1。原始惡意URL:http://127.0.0.1:8081/(8081是宿主機映射端口從容器內發請求到127.0.0.1是容器自己我們用它模擬攻擊第一次編碼編碼點號:http://127%2e0%2e0%2e1:8081/第二次編碼編碼百分號:http://127%252e0%252e0%252e1:8081/4.3 發起繞過攻擊現在我們用雙重編碼的Payload發起請求curl -s http://localhost:5000/api/fetch?urlhttp://127%252e0%252e0%252e1:8081/ | python3 -m json.tool同時觀察運行docker-compose up的終端查看過濾器 ([FILTER]) 和應用 ([APP]) 的打印日志vulnerable-app_1 | [FILTER] Input: http://127%252e0%252e0%252e1:8081/ vulnerable-app_1 | [FILTER] After first decode: http://127%2e0%2e0%2e1:8081/ vulnerable-app_1 | [FILTER] PASSED vulnerable-app_1 | [APP] Making request to: http://127%252e0%252e0%252e1:8081/發生了什么過濾器收到http://127%252e0%252e0%252e1:8081/。過濾器調用unquote()解碼一次得到http://127%2e0%2e0%2e1:8081/。此時主機名是127%2e0%2e0%2e1。過濾器的黑名單是[127.0.0.1, ...]它檢查127%2e0%2e0%2e1是否包含127.0.0.1。不包含因為字符串里是%2e而不是點號。所以過濾器放行了。應用拿到被放行的URLhttp://127%252e0%252e0%252e1:8081/調用requests.get()。requests.get()在發起請求前會對URL進行規范化其中包括URL解碼。它解碼一次將%25還原為%得到http://127%2e0%2e0%2e1:8081/。這還不夠它可能繼續解碼或者底層庫urllib3會處理最終將%2e解碼為.得到http://127.0.0.1:8081/。請求成功發送到127.0.0.1:8081即容器自身我們映射了Nginx服務并返回了管理頁面的內容。curl命令的返回結果中你應該能看到INTERNAL ADMIN PANEL和Secret Flag的內容。這證明雙重編碼繞過成功5. 漏洞根源與深度分析5.1 為什么過濾器會失效解碼時機不一致這是根本原因。安全過濾器在驗證時只進行了一次解碼或解碼不徹底而后端HTTP客戶端庫在發起請求時進行了完整的、可能多次的解碼。這種差異導致了“檢查時一個樣子執行時另一個樣子”的經典漏洞模式。檢查邏輯過于依賴字符串匹配過濾器使用in進行子字符串匹配而不是先徹底規范化解碼、解析、提取主機名、解析IP再進行嚴格的比對。這使得編碼可以輕易擾亂匹配過程。缺乏規范化Canonicalization安全的做法是將輸入URL完全規范化為一個標準形式后再進行檢查。這包括多次解碼直到沒有可解碼的%XX序列為止。解析主機名如果是域名考慮解析為IP地址注意DNS重綁定的風險。將IPv4、IPv6的各種表示形式如八進制、十六進制、整數格式統一轉換為標準點分十進制或規范格式。5.2 其他可能被利用的編碼方式雙重編碼只是編碼繞過的一種。攻擊者還可能嘗試八進制IP地址127.0.0.1-0177.0.0.1(0177 127 octal) - 編碼后http://0177%2e0%2e0%2e1。十六進制IP地址127.0.0.1-0x7f.0.0.1(0x7f 127 hex) - 編碼。十進制整數IP2130706433是127.0.0.1的十進制表示。某些庫或配置可能接受這種格式。URL中嵌入CRLF%0d%0a(CRLF) 編碼可能用于注入HTTP頭。混合編碼在URL的不同部分使用不同的編碼方式。6. 修復方案與最佳實踐如何構建一個健壯的、能防御雙重編碼及其他繞過手法的SSRF過濾器6.1 修復后的過濾器代碼# secure_filter.py - 增強版SSRF過濾器 from urllib.parse import urlparse, unquote import ipaddress import re import socket def secure_ssrf_filter(url): 增強的SSRF安全檢查函數。 原則徹底規范化然后基于IP地址進行判斷。 try: # 1. 徹底解碼循環解碼直到沒有百分號編碼 decoded_url url while % in decoded_url: new_decoded unquote(decoded_url) if new_decoded decoded_url: # 解碼未產生變化停止循環 break decoded_url new_decoded print(f[SECURE FILTER] Fully decoded URL: {decoded_url}) # 2. 解析URL parsed urlparse(decoded_url) if not parsed.scheme: return False, URL scheme is missing if parsed.scheme not in [http, https]: return False, fUnsupported scheme: {parsed.scheme} hostname parsed.hostname if not hostname: return False, Could not determine hostname # 3. 解析主機名到IP地址關鍵步驟 # 注意這里會觸發DNS解析。在生產環境中需要謹慎處理DNS重綁定攻擊。 # 可以考慮使用自定義解析器或設置解析超時并緩存結果。 try: # 獲取所有關聯的IP地址 ip_list [] for info in socket.getaddrinfo(hostname, None): ip_list.append(info[4][0]) # 獲取IP地址 # 去重 ips set(ip_list) except socket.gaierror: # 如果無法解析可能是無效域名或內部域名。 # 安全策略無法解析則拒絕。或者如果允許特定域名可加入白名單。 return False, fCould not resolve hostname: {hostname} print(f[SECURE FILTER] Resolved IPs for {hostname}: {ips}) # 4. 檢查每個解析出的IP地址 for ip_str in ips: try: ip ipaddress.ip_address(ip_str) # 禁止所有內網和回環地址 if ip.is_private or ip.is_loopback: return False, fAccess to private/loopback IP ({ip}) is forbidden. Original hostname: {hostname} # 還可以禁止其他特殊地址如鏈路本地、多播等 # if ip.is_link_local or ip.is_multicast: # return False, fAccess to special IP ({ip}) is forbidden. except ValueError: # 非IP地址理論上不會走到這里因為來自getaddrinfo pass # 5. 可選應用層協議或路徑黑名單/白名單 # 例如禁止訪問 /admin, /api/internal 等路徑 forbidden_paths [^/admin, ^/internal] for fp in forbidden_paths: if re.search(fp, parsed.path): return False, fForbidden path pattern accessed: {fp} # 6. 所有檢查通過 return True, URL passed all security checks except Exception as e: # 記錄詳細日志但返回通用錯誤信息 print(f[SECURE FILTER] Error during validation: {e}) return False, Internal security validation error # 測試修復 if __name__ __main__: test_cases [ http://127.0.0.1, http://127%2e0%2e0%2e1, http://127%252e0%252e0%252e1, http://localhost, http://192.168.1.1, http://internal-admin, # 這個需要在實際有DNS或hosts的環境中測試 http://example.com, # 應該通過 file:///etc/passwd, http://admin:passwordexample.com, # 包含用戶信息 ] for tc in test_cases: safe, msg secure_ssrf_filter(tc) print(fURL: {tc:50} Safe: {safe:5} Msg: {msg})6.2 關鍵修復點解析徹底解碼規范化使用循環直到沒有可解碼的字符。這確保了無論攻擊者進行多少次編碼在檢查時都會被還原為原始形式。基于IP地址的檢查這是防御SSRF的黃金法則。不要信任主機名域名因為localhost、127.0.0.1、0.0.0.0、2130706433、0177.0.0.1最終都可能指向回環地址。通過socket.getaddrinfo()解析出所有IP然后對這些IP應用安全規則。警惕DNS重綁定getaddrinfo會進行DNS查詢。攻擊者可能控制一個域名第一次解析返回一個公網IP通過過濾器但在TTL極短的情況下第二次解析實際請求時返回一個內網IP。防御方法包括使用固定的、可信的DNS解析器在過濾器內立即對解析出的IP發起一個HEAD請求小心循環請求或設置極短的DNS緩存時間并重新解析。白名單優于黑名單如果業務允許只允許訪問一組預先定義好的、可信的外部域名/IP白名單這是最安全的策略。協議限制嚴格限制只允許http和https協議。路徑和查詢參數審查即使主機名是合法的也要檢查請求的路徑和參數是否可能用于攻擊內部服務例如利用已知漏洞的特定API路徑。6.3 工程化最佳實踐使用成熟的庫或中間件不要自己從頭實現SSRF過濾器。社區維護的庫如Java中的SSRFProtectorPython的security相關包通常經過更多測試。在Web框架層面如Spring Security Filter, Django Middleware集成防護。統一請求客戶端確保應用程序內所有出站HTTP請求都通過一個統一的、經過安全加固的客戶端發出。這個客戶端內部集成了SSRF檢查。網絡層隔離在云原生或容器化環境中使用網絡策略Network Policies或安全組Security Groups嚴格限制應用容器的網絡出口禁止其訪問生產環境的內網關鍵段。縱深防御SSRF防護不應只依賴應用層過濾器。結合網絡層防火墻、主機層防火墻、服務認證等多層防護。日志與監控對所有出站請求尤其是被過濾器攔截的進行詳細日志記錄和監控以便及時發現攻擊嘗試。7. 總結與拓展思考雙重編碼繞過揭示了Web安全中一個深刻的問題數據在應用不同層間傳遞時其解析和解釋的一致性至關重要。任何在驗證點和執行點之間對數據處理的差異都可能成為攻擊者利用的突破口。對于開發者而言防御此類漏洞需要樹立規范化意識在處理用戶輸入前將其轉換為唯一、標準的格式。理解依賴庫的行為清楚你使用的HTTP客戶端、URL解析庫在背后做了什么解碼、重定向、協議處理等。采用零信任策略對任何用戶提供的、用于網絡訪問的標識符主機名、IP、URL都保持懷疑并進行最嚴格的驗證。對于安全測試人員雙重編碼是SSRF測試武器庫中的一件利器。在測試時可以系統性地嘗試以下Payload變種http://127%2e0%2e0%2e1http://127%252e0%252e0%252e1http://0x7f.0.0.1http://2130706433http://localhost%252ecom(利用域名后綴繞過localhost黑名單)最后安全是一個持續的過程。隨著防御手段的升級攻擊技術也在演化。保持學習理解底層原理才能在攻防對抗中占據主動。