资讯动态

SSRF双重编码绕过原理与实战:从编码机制到安全过滤器防御

发布时间:2026/8/21 22:53:35 来源:尧图企业网站定制
在渗透测试和漏洞挖掘过程中我们常常会遇到部署了安全过滤器的应用它们旨在拦截恶意的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黑名单)最后安全是一个持续的过程。随着防御手段的升级攻击技术也在演化。保持学习理解底层原理才能在攻防对抗中占据主动。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价