Python安全漏洞及防护总结
文章目录
1.输入输出数据安全
1.1跨站脚本攻击
1.1.1 风险描述
跨站脚本攻击,即XSS攻击,可分为如下三种类型:
1.持久型XSS,也称存储型XSS,是指应用程序将用户发送的不可信赖数据在未经过滤、转义的情况下,将其存入数据库中,后面应用程序从数据库中获取到该数据并在未经过滤、转义的情况下输出到页面时,数据中包含的恶意JavaScript代码将会被执行。
2.非持久型XSS,可称反射型XSS,是指应用程序将用户发送的不可信赖数据在未经过滤、转义的情况下,直接输出到了页面,致使其中包含的恶意JavaScript代码被执行。
3.DOM型(文档对象模型)XSS,是一种特殊的反射型XSS,二者的区别是反射型XSS数据会经过后台然后再输出到页面,而DOM型XSS数据不经过后台,是由于直接在前端JavaScript代码中对用户请求参数做处理后,未经过滤、转义操作直接输出到页面上引起的漏洞。
- dom型xss https://www.jianshu.com/p/190dedd585f2
- 与反射型的区别,提交的脚本由前端js修改dom,后端不处理
攻击者可通过XSS漏洞进行劫持用户Cookie、构建Get和Post请求、获取用户系统信息、XSS蠕虫攻击等。
如下代码将用户请求参数q的值未做任何过滤、转义操作直接响应给了客户端,存在反射型XSS风险:
def search(request):
request.encoding = 'utf-8'
if 'q' in request.GET and request.GET['q']:
message = '搜索内容为:' + request.GET['q']
else:
message = '空表单!'
return HttpResponse(message)
1.1.2 规范要求
1.对客户端输入数据(包括但不限于GET和POST请求数据、HTTP请求头数据等)进行严格校验:
(1)通过正则校验限制输入数据中可接受的字符集合(如针对用户提交的个人信息,
可限制用户名只能包含数字和字母、限制性别值只能为‘0’或‘1’等)。
(2)过滤如下恶意字符,一旦匹配成功则拒绝请求:
["'", ">", "<", "=", "|", "&", ")", "(", "/", "#", "+", "-", "*", ":", ".", ";", "%", "\"", "onerror", "onkeyup", "onclick", "oncomplete", "onload", "onmouseover", "onmouseout", "onabort", "onblur", "onchange", "ondblclick", "onfocus", "onkeydown", "onkeypress", "onmousedown", "onmouseup", "onreset", "onresize", "onselect", "onsubmit", "onunload", "javascript", "script", "frame", "src", "cookie", "style", "expression"]
(3)输入数据为数字型参数时,必须进行强制·类型转换来校验数据的合法性。
(4)限制输入数据的长度,来加大攻击者的攻击难度。
2.输入数据的安全性不能依赖于客户端验证,必须在服务端对其进行最终验证。(客户端的验证只能作为辅助手段用于减少客户端和服务端的信息交互次数)
3.将不可信数据输出到页面之前,必须进行编码处理:
(1)使用Django模板显示数据时,默认会对变量值进行html编码和Javascript编码。
但如果在{{}}标签中使用safe过滤器或者在后端使用mark_safe函数将数据标记为安全时,数据是不会被进行编码处理的,因此一定要在能够保证数据绝对安全的情况下,才能将数据标记为安全。
(2)在不使用Django模板的场景中,使用html.escape函数对输出数据进行编码处理,默认情况下,该函数会对数据进行html编码和javascript编码。
4.在重要的Cookie中加入HttpOnly来防止跨站脚本盗取Cookie(服务端set-cookie时添加httponly的flag来限制跨站脚本对cookie的访问,前提浏览器支持httponly)。
5.可结合具体业务场景搭配使用输入过滤和输出编码。
1.1.3 代码示例
def search(request):
request.encoding = 'utf-8'
if 'q' in request.GET and request.GET['q']:
message = '搜索内容为:' + html.escape(request.GET['q'])
else:
message = '空表单!'
return HttpResponse(message)
1.2 跨站请求伪造
1.2.1 风险描述
跨站请求伪造是指攻击者通过伪造客户端请求来以应用系统用户的名义去执行操作的一种攻击方式,攻击者利用该漏洞可以用户的名义执行发送邮件、添加系统管理员、购买商品、转账等操作。
当Django项目禁用django.middleware.csrf.CsrfViewMiddleware中间件时,如下修改密码的代码即存在CSRF攻击风险:
def update(request):
password_new = request.POST["password_new"]
password_conf = request.POST["password_conf"]
if password_new == password_conf:
user = User.objects.get(uid=request.session["uid"])
user.password = password_new
user.save()
攻击者可在其服务器上创建钓鱼页面,其中包含构造的发送修改密码请求的代码,当用户在已登录网站的情况下点击攻击者发送的钓鱼链接时,密码将被修改。
1.2.2 规范要求
1.使用白名单对HTTP请求的Referer头进行限定。
2.在Django的settings文件中,加入django.middleware.csrf.CsrfViewMiddleware中间件,并在模板文件中使用{% csrf_token %}标签为POST请求表单(form)添加CSRF标记。
3.对于安全性要求较高的操作,使用验证码、密码等进行二次校验。
1.2.3 代码示例
在Django项目中,通过如下方式引入django.middleware.csrf.CsrfViewMiddleware中间件,可有效防止CSRF攻击:
MIDDLEWARE = [
'django.middleware.csrf.CsrfViewMiddleware',
]
- get请求,django的csrf中间件只验证了cookie中是否包含csrf_token,所以无法限制get请求的,故添加了referer进行限制(但是很少有网站会通过get请求进行数据的修改)
- 对于post请求,django的csrf中间件,除了会验证cookie中的csrt_token,还会取表单中的csrfmiddlewaretoken,ajax会取header中的X-CSRFToken。用cookie中的csrf_token和另外一个进行比较,相等就通过,不相等就403
1.3 日志伪造(同XSS持久型)
1.3.1 风险描述
将未经验证的用户输入写入日志系统,会导致日志伪造攻击。如下代码中攻击者通过构造username值可伪造一些敏感的日志。另一方面如果日志处理程序将日志展示在浏览器上,则攻击者可通过在日志中混入HTML代码进行XSS攻击
username = request.POST["username"]
if not is_exists(username):
log.info("username not exists: " + username)
1.3.2 规范要求
-
避免直接将不可信数据送入日志处理系统。
-
无法避免时:
(1)使用白名单限制可记录到日志中的数据集合。
(2)白名单过大时,使用正则校验限定用户输入数据中可包含的字符集(如只允许字母和数字)。
(3)过滤恶意字符,如“\r”、“\n”、“<>”、“'”、“\””等,完整列表可参考7.1.1.2 跨站脚本攻击的规范要求。
1.3.3 代码示例
如下代码,在将请求参数username值记录到日志之前,对其进行了正则校验,判断其是否只包含数字字母:
username = request.POST["username"]
if not is_exists(username):
if re.match("^[a-zA-Z0-9]+$", username):
logging.error("username not exists: " + username)
1.4 反序列化
1.4.1 风险描述
反序列化,是将字符串加载到内存中的变量中的过程。Python中常用的模块有:pickle、PyYAML、cPickle
pickle是个不安全的模块,不要反序列化不信任的数据。
def pickle(request):
request.encoding = 'utf-8'
if 'q' in request.GET and request.GET['q']:
real_q = base64.b64decode(request.GET['q'])
msg = pickle.loads(real_q)
else:
msg = "反序列化内容为空"
return (msg)
采用下面的脚本,生成反序列化的攻击代码
import pickle
import os
import base64
class A():
def __reduce__(self):
return (os.system, ("/bin/sh -c 'echo RCE!'", ))
a = A()
test = pickle.dumps(a) # 执行dumps时会调用__reduce__,返回元组,第一个参数为函数指针,第二个参数是个元组,里面放置函数参数
print(base64.b64encode(test))
可以造成命令执行。
pickle.loads(test) # 返回 0
1.4.2 规范要求
- 对于允许反序列化的输入必须要保持警惕。
- 对于pickle反序列化,官方给出了使用改写
Unpickler.find_class()
方法,引入白名单的方式来解决。对于开发者而言,如果实在要给用户反序列化的权限,最好使用双白名单限制module
和name
并充分考虑到白名单中的各模块和各函数是否有危险。
import io
import pickle
import posix
# import posix
safe_builtins = ['range', 'complex', 'set', 'slice']
# 白名单name
names = ["system"] + safe_builtins
# 白名单module
modules = ["builtins", ] # 未开放 "posix" module
# modules = ["builtins", "posix"] # 开放 "posix" module
class RestrictedUnpickler(pickle.Unpickler): # 接收file对象
def find_class(self, module, name):
if module in modules and name in names:
return getattr(eval(module), name)
raise pickle.UnpicklingError("global '%s.%s' is forbidden" % (module, name))
def restricted_loads(s):
"""Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load() # init时接受一个字节流对象,可以通过写入io.BytesIO(), 调用load时会调用find_class进行校验
-
对于YAML的反序列化,使用
safe_load
函数- 关于YAML https://cloud.tencent.com/developer/article/1850731
-
漏洞成因
# 创建my_test_yaml.py import yaml import os class Poc(object): def __init__(self): os.system("cat /etc/passwd") dangerous_input = yaml.dump(Poc()) dangerous_input = dangerous_input.replace("__main__", "my_test_yaml") fp = open("./test.yaml", 'w') fp.write(dangerous_input) fp.close() # python3 my_test_yaml.py
# 创建yaml_verify.py import yaml print(yaml.load(open("./test.yaml"), yaml.CLoader)) # python3 yaml_verify.py
# 通过跟踪$PYTHON_HOME/lib/site-packages/yaml/constructor.py文件,查看PyYAML源码可以得到其针对Python语言特有的标签解析的处理函数对应列表,其中有三个和对象相关: !!python/object: => Constructor.construct_python_object !!python/object/apply: => Constructor.construct_python_object_apply !!python/object/new: => Constructor.construct_python_object_new 从上面的代码中可以看到" !!python/object/new " 标签的代码实现其实就是" !!python/object/apply "标签的代码实现,只是最后newobj参数值不同而已,其次可以看到的是这3个Python标签中都是调用了make_python_instance()函数,之后查看该函数: 从上述代码中可以看到,该函数会根据参数来动态创建新的Python类对象或通过引用module的类创建对象,从而可以执行任意命令~
import yaml
dangerous_input ="!!python/object/apply:os.system ['cat /etc/passwd']" # 对于此字符串反序列化会执行命令
yaml.load(dangerous_input, yaml.CLoader) # 5.1版本弃用了yaml.load(f)这种写法,必须指定加载器,但是CLoader加载器还是沿用了以上的不安全的标签(即可加载任意python对象或函数)
yaml.safe_load(dangerous_input) # 默认使用SafeLoader, 不包含上述标签,故无法解析,抛出异常
# 提示报错
2.DAO(Data Access Object)层数据操作安全
2.1 SQL注入
2.1.1 风险描述
SQL注入是一种数据库攻击手段。造成SQL注入攻击的根本原因在于攻击者可以改变SQL查询的上下文,使程序原本要作为数据解析的数值,被攻击者通过提交恶意代码后改变了原SQL语句的含义,造成执行任意SQL命令,达到入侵数据库乃至操作系统的目的。如下代码即存在SQL注入风险:
username = request.POST["username"]
sql = "select * from db_users where userLogin = '" + username + "'"
cursor.execute(sql)
result = cursor.fetchall()
当攻击者提交的username值为 ' or '1'='1
时,查询就变成了:
select * from db_users where userLogin = '' or '1'='1'
这样攻击者就可以获取任意用户的信息了。
2.1.2 规范要求
-
使用参数化API进行SQL查询,尽量使用Django提供的操作数据库的API进行SQL查询。(
实际上就是对敏感字符进行转义
) -
无法使用参数化查询时:
(1)创建白名单规定可拼接到SQL查询中的数据的集合。
(2)白名单过大时,使用正则校验限定可拼接到SQL查询中的数据中可包含的字符集(如只允许字母和数字)。
(3)使用pymysql模块提供的escape_string方法对拼接到SQL查询中的数据中的“’”、“””和“\”等字符进行转义。
(4)过滤如下恶意字符,一旦匹配成功则拒绝请求:
array("select", "from", "insert", "update", "drop", "exec", "delete","truncate", "and", "union", "or", "'", ">", "<", "=", "|", "&", ")", "(", "/", "#", "+", "-", "*", ":", ".", ";", "%", "\"");
- 避免向客户端响应详细的错误消息,防止攻击者利用报错信息来判断后台SQL的拼接形式,甚至是直接利用报错注入将数据库中的数据通过报错信息显示出来。
2.1.3 代码示例
1.如下使用参数化查询可有效避免SQL注入攻击:
username = request.POST["usernamename"]
sql = "select * from db_users where userLogin = %s"
cursor.execute(sql, username)
result = cursor.fetchall()
res = ""
response = User.objects.order_by(request.POST["order"])
for var in response:
res += "<p>" + html.escape(var.name) + "</p>"
return HttpResponse(res)
2.使用Django提供的操作数据库的API进行SQL查询(ORM)
2.2 LDAP注入(同sql注入)
LDAP概念
- 轻型目录访问协议(英文:Lightweight Directory Access Protocol,缩写:LDAP,/ˈɛldæp/)是一个开放的,中立的,工业标准的应用协议,通过IP协议提供访问控制和维护分布式信息的目录信息。
- 目录服务在开发内部网和与互联网程序共享用户、系统、网络、服务和应用的过程中占据了重要地位。例如,目录服务可能提供了组织有序的记录集合,通常有层级结构,例如公司电子邮件目录。同理,也可以提供包含了地址和电话号码的电话簿。
LDAP相关名词
- DC:domain component一般为公司名,例如:dc=163,dc=com
- OU:organization unit为组织单元,最多可以有四级,每级最长32个字符,可以为中文
- CN:common name为用户名或者服务器名,最长可以到80个字符,可以为中文
- DN:distinguished name为一条LDAP记录项的名字,有唯一性,例如:dn:“cn=admin,ou=developer,dc=163,dc=com”
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lWtREMh2-1658802517703)(./images/ldap.png)]
2.2.1 风险描述
-
ldap介绍
https://blog.csdn.net/yangyangrenren/article/details/122017001
-
openldap安装
https://blog.csdn.net/smile_pbb/article/details/124844775?spm=1001.2014.3001.5506 -
python 安装第三方库
pip install python-ldap
LDAP注入是指客户端发送查询请求时,输入的字符串中含有一些特殊字符,导致修改了LDAP本来的查询结构,从而使得可以访问更多的未授权数据。如下代码即存在LDAP注入风险:
import ldap
# process: slapd
l = ldap.initialize("ldap://localhost:389") #
ROOT_DN = "dc=qianxin,dc=com" # 选择根节点,相当于key
scope = ldap.SCOPE_SUBTREE # 查询范围, 查询子树
# 非法user如通配符 *
user = "*"
query = "(&(uid=%s))" % user # 查询条件(&(uid=*))
l.search_s(ROOT_DN, scope, query)
2.2.2 规范要求
-
设置白名单来限制只能拼接到LDAP查询的数据集合。
-
白名单过大时,使用正则校验限定用户输入数据中可包含的字符集(如只允许字母数字)。
-
过滤用户输入数据中可能用作LDAP查询的字符(如可使用ldap.filter模块提供的escape_filter_chars方法进行过滤)。
from ldap.filter import escape_filter_chars
user = escape_filter_chars(user) # 特殊字符会进行转移为ASCII码
query = "(&(uid=%s))" % user
l.search_s(ROOT_DN, scope, query) # 从而查询无结果
3 XML数据操作安全
3.1 Xpath注入(同sql注入)
3.1.1 风险描述
使用不可信数据源的数据来构造并执行XPath查询,就有可能会发生XPath注入攻击。攻击者可以利用此方式获取未授权的数据,或者篡改这些数据。
如下代码即存在Xpath注入风险:
def login(request):
tree = etree.parse('user.xml')
query = "//users[username='" + request.GET["username"] + "'][password='" + request.GET["password"] + "']"
res = tree.xpath(query)
当攻击者输入如下数据时:
username:admin
password:xxx' or '1'='1
Xpath实际执行的查询语句如下,这时便绕过了密码检查:
//users[username='admin'][password='xxx' or '1'='1']
3.1.2 规范要求
-
设置白名单来限制可拼接到Xpath查询的数据集合。
-
白名单过大时,使用正则校验限定用户输入数据中可包含的字符集(如只允许字母数字)。
-
过滤用户输入数据中可能用作Xpath查询的字符。
3.1.3 代码示例
- 如下代码通过限定用户只能输入数字来避免Xpath注入的风险:
def login(request):
tree = etree.parse('user.xml')
query = "//users[username='" + str(int(request.GET["username"])) + "'][password='" + str(int(request.GET["password"])) + "']"
res = tree.xpath(query)
- 使用如下代码对恶意输入进行过滤:
def xmli_check(data):
data = data.replace("(", "")
data = data.replace(")", "")
data = data.replace("=", "")
data = data.replace("'", "")
data = data.replace("[", "")
data = data.replace("]", "")
data = data.replace(":", "")
data = data.replace(",", "")
data = data.replace("*", "")
data = data.replace("/", "")
data = data.replace(" ", "")
return data
3.2 XML外部实体注入(XXE)
3.2.1 风险描述
应用在解析不可信来源的XML数据时,没有禁止外部实体的加载,致使攻击者通过发送恶意构造的XML数据对应用系统造成文件读取、命令执行、内网端口探测、拒绝服务攻击等危害。
如下代码使用lxml模块解析用户发送的xml数据用来校验用户身份:
from lxml import etree
def login(request):
tree = etree.fromstring(request.body.decode('utf-8').encode('utf-8'))
for child in tree:
if child.tag == 'username':
username = child.text # 获取时会引入外部实体
if child.tag == 'password':
password = child.text
if verify(username, password):
res = '<result><code>%d</code><msg>%s</msg></result>' % (1, username)
else:
res = '<result><code>%d</code><msg>%s</msg></result>' % (0, username)
return HttpResponse(res)
当攻击者发送如下post请求时,将造成/etc/passwd文件内容泄露:
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE root [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<users><username>&xxe;</username><password>xxx</password></users>
- 原理:xml实体注入是DTD引用
外部实体
导致的漏洞<!ENTITY xxe SYSTEM "file:///etc/passwd">
- 一个外部实体声明
- 语法
<!ENTITY entity-name SYSTEM "URI/URL">
3.2.2 规范要求
-
禁用加载外部实体的功能。
-
无法禁用外部实体时,可过滤用户请求数据中的关键字(如<!DOCTYPE、<!ENTITY、SYSTEM、PUBLIC等)。
3.2.3 代码示例
通过如下代码禁用掉加载外部实体的功能,可有效避免出现XXE风险:
def login(request):
# 禁用解析外部实体
parser = etree.XMLParser(resolve_entities=False)
tree = etree.fromstring(request.body.decode('utf-8').encode('utf-8'), parser=parser)
for child in tree:
if child.tag == 'username':
username = child.text
if child.tag == 'password':
password = child.text
if verify(username, password):
res = '<result><code>%d</code><msg>%s</msg></result>' % (1, username)
else:
res = '<result><code>%d</code><msg>%s</msg></result>' % (0, username)
return HttpResponse(res)
3.2.4 演示
cat > /home/nss/xxe.xml <<EOF
<?xml version="1.0"?>
<!DOCTYPE root [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<users><username>&xxe;</username><password>xxx</password></users>
EOF
from lxml import etree
f = open("/home/nss/xxe.xml", "r")
data = f.read()
tree = etree.fromstring(data)
f.close()
tree[0].text # 此时引入外部实体
- 禁用外部实体
from lxml import etree
f = open("/home/nss/xxe.xml", "r")
data = f.read()
parser = etree.XMLParser(resolve_entities=False)
tree = etree.fromstring(data, parser=parser)
f.close()
tree[0].text # 返回None
4 Response数据安全
4.1 任意重定向
4.1.1 风险描述
未经验证的用户输入被当成重定向的URL或URL的一部分时,将导致重定向攻击。如下代码当用户提交url参数为一个恶意链接时,应用将会跳转到恶意网站:
- 场景 用户登陆后跳转
def goto(request):
url = request.GET['url']
return redirect(url)
4.1.2 规范要求
-
避免将不可信的用户输入作为URL或URL的一部分。
-
如果无法避免,则应使用白名单来严格限制用户可以重定向的地址。
-
校验HTTP Referer头是否合法。(未理解)
4.1.3 代码示例
如下代码使用白名单限定方式有效避免了任意重定向风险:
def goto(request):
url = request.GET['url']
if url == '1':
return redirect('http://test1.hldf.com')
elif url == '2':
return redirect('http://test2.hldf.com')
else:
return redirect('http://test3.hldf.com')
4.2 HTTP头操纵(未复现)
4.2.1 风险描述
应用程序从一个不可信的数据源获取数据,未进行验证就置于HTTP头部中响应给用户,可能会使HTTP头部被篡改,导致跨站脚本、网页劫持、Cookie篡改、重定向、缓存中毒、用户信息涂改等攻击。
如下代码将用户请求参数filename值未经过滤就加入了响应头进行响应,存在HTTP头操纵风险:
def download(request):
filename = request.GET['filename']
file = open('d:/docs/news.doc', 'rb')
response = FileResponse(file)
response['Content-Type'] = 'application/octet-stream'
response['Content-Disposition'] = 'attachment; filename=' + filename
return response
4.2.2 规范要求
-
设置白名单来限制只能添加该列表中的数据到HTTP响应头中。
-
白名单过大时,使用正则校验来限定加入HTTP响应头中的数据中可包含的字符集(如只允许字母和数字)。
-
对加入HTTP响应头中的恶意数据进行过滤,如“\n”, “\r”等。
4.2.3 代码示例
如下代码通过正则校验只允许包含字母数字的数据加入HTTP响应头中:
def download(request):
filename = request.GET['filename']
file = open('d:/docs/news.doc', 'rb')
response = FileResponse(file)
response['Content-Type'] = 'application/octet-stream'
# URL解码 将 %0A -> \n and %0D -> \r
filename = parse.unquote(filename)
if re.match('^[a-zA-Z0-9]+$', filename):
response['Content-Disposition'] = 'attachment; filename=' + filename
return response
# 直接输入"\n\r"会抛异常
# Exception Type:BadHeaderError
# Exception Value:Header values can't contain newlines
# Exception Location:/usr/local/lib/python3.9/site-packages/django/http/response.py, line 53, in _convert_to_charset
# 虽然标准的 HTTP 报文中要求首部的每一行都要使用换行符隔开,但是 django 已经帮我们做了这些,所以我们不用重复的添加换行符了,否则会触发 BadHeaderError 异常。
# {'Content-Type': 'application/octet-stream', 'Content-Length': '1720', 'Content-Disposition': "attachment; filename=123;%0A%0DSet-Cookie='abc'"}
# 并不会对%0A,%0D进行转义,而是直接校验\n\r,防止出现http头操纵
5 文件操作安全
5.1 文件上传
5.1.1 风险描述
由于业务需要,应用程序通常允许用户上传图片或附件,如果程序没有对上传的文件进行检查的话,攻击者可能会通过上传文件的功能上传一些恶意文件,如webshell(网页脚本文件)(https://zhuanlan.zhihu.com/p/344629093)、病毒文件(恶意脚本)等,可直接获取用户服务器权限,或执行恶意脚本、挂黑页等操作。
大部分网站都会有文件上传的功能,例如头像、图片、视频等,这块的逻辑如果处理不当,很容易触发服务器漏洞。这种漏洞在以文件名为 URL 特征的程序中比较多见。
如下文件上传代码可以看到虽然上传的文件目录是不可被解析执行的,但是由于没有限制文件后缀,可以结合其他类型的漏洞进行攻击
(命令注入):
- 关键点:
- 可上传(绕过)
- 可执行
def upload(request):
if request.method == "GET":
return render(request, "upload.html")
if request.method == "POST":
myFile =request.FILES.get("myfile", None)
if not myFile:
return HttpResponse("no files for upload!")
destination = open(os.path.join("d:/images", myFile.name), 'wb+')
for chunk in myFile.chunks():
destination.write(chunk)
destination.close()
return HttpResponse("upload over!")
5.1.2 规范要求
-
使用白名单方式限制可上传的文件类型。
-
限制允许上传的文件大小。
-
关闭文件上传目录的可执行权限。
-
使用强随机数值改写文件名和文件路径。
-
处理图片时,采用压缩函数或者resize函数,在处理图片的同时破坏图片中可能包含的脚本代码
5.1.3 代码示例
如下文件上传代码,使用白名单校验文件后缀,且随机生成文件名,在一定程度上提高了攻击成本:
def getRandomFilename(length):
chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
res = ""
for i in range(length):
j = random.randint(0, 61)
res += chars[j: j+1]
return res
def upload(request):
if request.method == "GET":
return render(request, "upload.html")
if request.method == "POST":
allowedExts = ["jpg", "gif", "jpeg", "png"]
myFile = request.FILES.get("myfile", None)
if not myFile:
return HttpResponse("no files for upload!")
# 获取文件名后缀
ext = os.path.splitext(myFile.name)[1][1:]
# 文件名后缀转小写,避免被大小写绕过
ext = ext.lower()
if ext in allowedExts and myFile.size < 204800:
# 保存文件时文件名随机生成
destination = open(os.path.join("d:/images", getRandomFilename(10) + '.' + ext), 'wb+')
for chunk in myFile.chunks():
destination.write(chunk)
destination.close()
return HttpResponse("upload over!")
else:
return HttpResponse("upload error!")
5.2 文件下载
5.2.1 风险描述
应用程序对用户输入数据未经合理校验,就作为路径传送给一个操作文件的API,将导致路径遍历攻击。攻击者可能会使用一些特殊的字符(如“…”和“/”)绕过限制,访问一些受保护的文件或目录。如下代码即存在路径遍历风险:
def download(request):
uuid = request.GET['uuid']
file = open('d:/docs/' + uuid, 'rb')
response = FileResponse(file)
response['Content-Type'] = 'application/octet-stream'
response['Content-Disposition'] = 'attachment; filename=' + uuid
return response
5.2.2 规范要求
-
避免让用户提交的数据作为读/写/上传/下载文件的路径或文件名。
-
无法避免时:
(1)使用白名单来限制用户可读/写/上传/下载文件的路径或文件名。
(2)白名单过大时,使用正则校验限定用户输入数据中可包含的字符集(如只允许字母和数字)。
(3)过滤用户输入数据中可用于路径遍历的恶意字符(如“…”、“/”、“\”等)。
5.2.3 代码示例
如下代码通过过滤恶意字符一定程度上避免了路径遍历问题:
def directory_traversal_check(filename):
if filename.startswith("/"):
return False
if filename.find('../') >= 0 or filename.find('..\\') >= 0 or filename.find('/..') >= 0 or filename.find('\\..') >= 0:
return True
return False
def download(request):
uuid = request.GET['uuid']
# 校验文件名
if directory_traversal_check(uuid):
return HttpResponse('文件名非法!')
file = open('d:/docs/' + uuid, 'rb')
response = FileResponse(file)
response['Content-Type'] = 'application/octet-stream'
response['Content-Disposition'] = 'attachment; filename=' + uuid
return response
6 服务端网络资源请求安全
6.1 服务端请求伪造
6.1.1 风险描述
通常服务器也能作为客户端,去请求其他的网络资源,如果攻击者能控制服务器所发起的网络连接,则根据环境不同,可造成一系列攻击,包括任意文件读取、内网主机和端口探测、攻击内网应用等等。
如下代码即存在服务器端请求伪造攻击风险:
def goto(request):
url = request.GET['url']
f = urllib.request.urlopen(url)
return HttpResponse(f)
6.1.2 规范要求
-
避免使用不可信来源的数据构造服务器端的请求。
-
无法避免时:
(1)使用白名单限制可请求的域名集合。
(2)禁止对内网ip发送请求。
(3)禁用不需要的协议,如仅允许http和https请求,防止类似file:/// 等协议引起的安全风险。
(4)白名单限制http请求的端口,如只允许80、443、8080等。
- 避免向客户端响应详细的错误消息,防止攻击者根据错误信息判断服务器的端口状态。
6.1.3 代码示例
如下代码对服务端发送的请求做了严格校验,可有效避免发生SSRF攻击风险:
def ssrf_check(url):
parsed_url = urlparse(url)
# 限制请求协议
scheme = parsed_url.scheme
allowedSchemes = ['http', 'https']
if scheme not in allowedSchemes:
return True
# 限制请求端口
port = parsed_url.port
allowedPorts = ['80', '443', None]
if port not in allowedPorts:
return True
# 限制请求域名
host = parsed_url.hostname
allowedHosts = ['www.baidu.com']
if host not in allowedHosts:
return True
# 限制请求的后缀
visitType = parsed_url.path[parsed_url.path.rfind('.')+1:]
allowedTypes = ['gif', 'png', 'jpeg', 'jpg']
if visitType not in allowedTypes:
return True
# 黑名单限制禁止访问内网ip
ip = socket.gethostbyname(host)
deniedHosts = ['127.', '192.', '10.']
for i in deniedHosts:
if ip.startswith(i):
return True
return False
def goto(request):
url = request.GET['url']
if not ssrf_check(url):
f = urllib.request.urlopen(url)
return HttpResponse(f)
else:
return HttpResponse('error url')
7 执行系统命令安全
7.1 命令注入
7.1.1 风险描述
命令注入是指应用程序执行外部命令时,被当作命令的字符串或字符串的一部分是不可信的数据,程序没有对这些不可信的数据进行验证、过滤,导致程序执行恶意命令的一种攻击方式。如下代码即存在命令注入风险:
username = request.GET['username']
res = os.system("ls -l /home/" + username)
当username参数值为 ;rm –rf / 时,最终执行的命令将是 ls –l /home/;rm –rf / ,分号在Linux系统下是命令分隔符,系统会首先执行ls命令,然后执行rm命令。
7.1.2 规范要求
-
应用代码执行操作系统命令时应避免从客户端获取命令。
-
无法避免时:
(1)创建白名单对可执行的命令进行限制。
(2)白名单过大时,使用正则校验限定用户输入数据中可包含的字符集(如只允许字
母和数字)。
(3)对用户输入数据中的可导致执行任意命令的字符进行过滤。如:
s = ["|", ";", "&", "$", "<", ">", "`", "\\", "!"]
(4)可以使用Python中pipes模块提供的pipes.quote()函数(当参数有特殊字符时,在参数两边加上一对’'),对用户输入的数据进行处理
- 常见的存在命令执行风险的函数如下,当使用这些函数执行命令时,需注意命令注入风险:
os.system
os.popen
os.spaw*
os.exec*
os.open
os.popen
commands.call
commands.getoutput
Popen*
7.1.3 代码示例
如下通过限制username参数的取值只能包含字母和数字来避免命令注入风险:
username = request.GET['username']
if re.match("^[a-zA-Z0-9]+$", username):
res = os.system("ls -l /home/" + username)
8 访问控制安全
8.1 越权访问
8.1.1 风险描述
越权访问漏洞可分为如下三种类型:
-
水平越权,一种“基于数据的访问控制”设计缺陷引起的漏洞。也就是由于服务端在接收到客户端请求数据后进行操作时没有判断数据的所属对象,致使用户A可以访问到属于同一角色的用户B的数据。
-
垂直越权,一种“基于URL的访问控制”设计缺陷引起的漏洞,也叫权限提升攻击。是由于服务端没有做权限控制或权限控制存在缺陷,导致恶意用户只要猜测到管理页面的URL地址或者某些用于标识用户角色的参数信息等,就可以访问或控制其他角色拥有的数据,达到权限提升的目的(smac属于角色访问控制)。
-
未授权访问,是指一些需要经过身份验证才可以访问的授权页面或API接口等资源存在鉴权缺陷,导致其他用户可以通过url地址直接进行访问,从而导致重要功能被操作、敏感信息泄露等危害。
如下根据订单id号删除订单信息的代码即存在水平越权风险:
def delete(request):
oid = int(request.POST['orderID'])
order = Orders.objects.get(oid=oid)
order.delete()
8.1.2 规范要求
-
在用户对数据进行删除、修改、查询等操作时,必须判断数据的所属对象。
-
使用基于角色的访问控制模型为应用设计完整的权限控制模块。
-
拒绝所有未经授权的访问,对每一个需要授权访问的页面或API接口都必须核实用户是否已被授权执行当前操作。
-
避免使用客户端请求中的某一参数值作为判断用户权限的唯一标识。
8.1.3 代码示例
如下通过增加对订单信息所属对象uid值的判断有效避免了水平越权风险:
def delete(request):
oid = int(request.POST['orderID'])
uid = request.session['uid']
Orders.objects.filter(oid=oid, uid=uid).delete()
8.2 身份认证
8.2.1 风险描述
身份认证安全风险主要包括用户密码暴力破解、验证码无效、验证码绕过、验证码回显、图形验证码容易识别、无密码策略等。可造成的风险不限于撞库攻击、暴力破解、用户名枚举、任意用户登录等安全风险。属于非常重要的功能模块。
8.2.2 规范要求
-
密码复杂度要求:必须6-8位字符或以上,包含字母、数字、特殊字符。
-
短信或邮箱验证码只能使用一次,并且内容必须在服务端生成。验证码的有效时间不超过10分钟。验证码强度至少6位,并且要保证随机性,不可预测。验证短信或邮件的每个会话在1分钟内不超过5条。
-
图形验证码必须在服务端动态生成,保证随机性,每次验证后必须更新。字符必须添加干扰线,长度要求至少4位或以上。
-
禁止将验证码中的数值响应到前端,并在后端校验验证码。
8.2.3 代码示例
如下代码校验密码的复杂度:
def valid_pass(data):
chars = "[~!@#$%^&*()\\-_=+{};:<,.>?]"
flag_upper = False
flag_lower = False
flag_digit = False
flag_especial = False
if len(data) < 8:
return False
for i in data:
if flag_upper and flag_lower and flag_digit and flag_especial:
return True
if i.isupper():
flag_upper = True
if i.islower():
flag_lower = True
if i.isdigit():
flag_digit = True
if i in chars:
flag_especial = True
return False
9 应用系统配置安全
9.1 安全配置缺陷
9.1.1 风险描述
安全配置缺陷是最常见的安全问题,这通常是由于不安全的默认配置、不完整的临时配置、开源云存储、错误的 HTTP 标头配置以及包含敏感信息的详细错误信息等问题所造成的。
9.1.2 规范要求
-
保证所有组件都是最新版本,并具有适当的安全配置,包括删除不需要的配置和文件夹,关闭或屏蔽不必要的端口,更改默认口令。
-
对应用系统所在服务器,所使用的框架和第三方库进行安全配置。
-
验证应用程序资源是否被托管,例如javascript库、css样式表、Web字体由应用程序托管,而不是依赖于CDN或外部提供者。
-
删除web目录下存在敏感信息的备份文件、测试文件、临时文件、旧版本文件等。
-
产品不能运行在开发和Debug模式。
-
当前在用的操作系统没有已知的漏洞。
-
禁止启动不用的服务,例如,FTP、Telnet、SMTP等。
-
启动应用程序的系统用户必须是专用的、没有系统级别特权的用户和组
-
在部署之前,删除没有用的功能和测试代码。
9.2 敏感信息泄露
9.2.1 风险描述
当开发人员缺乏一定的安全意识,未按照安全规范进行编码时,会造成用户敏感信息和应用系统信息的泄露。
9.2.1 规范要求
-
不要在错误页面中泄露敏感信息,如系统详细信息、会话标识符、用户账号信息、系统物理路径、数据库路径、SQL语句等。
-
建议删除JavaScript的注释代码,避免注释代码中存在遗留的测试账号信息、敏感接口地址、以及第三方服务的access_key等敏感信息造成泄露。
-
避免在前端代码中存放敏感信息,如硬编码加密秘钥、Hidden字段存放管理员账号密码等。
-
禁止使用GET方法传递敏感参数(会话标识、身份证号等),因为GET方法会将数据显示在URL中,传输过程中所有的代理及缓存服务器都可以直接获取用户数据。
-
日志文件禁止保存在web目录下。
-
不要在日志中保存敏感信息,如回话ID、用户账号信息等。
-
密码类数据信息必须采用MD5+Salt的方式进行存储。
-
需要逆向的敏感信息(如身份证号、银行卡号等)在存储时建议使用高强度的加密算法(如AES)进行保护。
-
敏感信息(如身份证号、银行卡号等)在传输、显示过程中必须经过脱敏处理。
-
为所有敏感信息采用加密传输,并且确保加密协议为安全版本。
-
禁止带有敏感数据的Web页面缓存。
9.2.3 代码示例
在HTML页面进行如下配置,来更改浏览器的默认缓存设置,强制浏览器不进行缓存:
<html>
<head>
<meta http-equiv="Expires" content="0">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Cache-control" content="no-cache">
<meta http-equiv="Cache" content="no-cache">
</head>
</html>
在服务端代码中可用如下设置方式:
response['Expires'] = 0
response['Cache-Control'] = 'no-cache'
response['Pragma'] = 'no-cache'
10 密码安全
10.1 明文密码
10.1.1 风险描述
应用系统中用到的数据库密码、缓存密码、消息队列密码、第三方服务的access_key等敏感数据硬编码于代码中或以明文的形式存于配置文件中,均会降低系统的安全性,一旦源码泄露,系统将会存在很大的安全风险。
如下在settings.py文件中硬编码了mysql数据库的明文密码,存在一定的安全风险:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'djangotest',
'HOST': '127.0.0.1',
'PORT': '3306',
'USER': 'root',
'PASSWORD': 'root',
}
}
10.1.2 规范要求
-
应用系统中用到的密码应采取人工输入或其他安全的外部渠道获取的方式。
-
上述方案不可行时,系统中存放的的密码应采取加密处理。
10.1.3 代码示例
- 如下settings.py文件中mysql数据库密码已经过加密处理:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'djangotest',
'HOST': '127.0.0.1',
'PORT': '3306',
'USER': 'root',
'PASSWORD': aesutil.decrypt("PTAuqW5vTRGGjKxR2S0aXQ==", os.getenv('dbenckey'), os.getenv('dbenciv')),
}
}
- 上步调用的aesutil代码如下,建议采用安全性较高的AES加密算法及CBC模式进行加解密:
from Crypto.Cipher import AES
import base64
# 加密函数,str为明文,key为秘钥,iv为cbc模式的初始向量
def encryt(str, key, iv):
cipher = AES.new(key.encode('utf-8'), AES.MODE_CBC, iv.encode('utf-8'))
x = AES.block_size - (len(str) % AES.block_size)
if x != 0:
str = str + chr(x) * x
msg = cipher.encrypt(str.encode('utf-8'))
msg = base64.b64encode(msg)
return msg
# 解密函数,enStr为密文,key为加密使用的秘钥,iv为加密使用的初始向量
def decrypt(enStr, key, iv):
cipher = AES.new(key.encode('utf-8'), AES.MODE_CBC, iv.encode('utf-8'))
decryptByts = base64.b64decode(enStr)
msg = cipher.decrypt(decryptByts)
paddingLen = msg[len(msg) - 1]
return msg[0:-paddingLen]
-
第一步中可以看到解密用的秘钥和初始向量iv的值均使用genenv()函数从环境变量中获取,是因为如果将解密密钥硬编码于代码中,当系统源代码泄露时,攻击者可以很容易使用硬编码的密钥对密文进行解密。
-
采用上述方案对明文密码进行加密处理时,可能会遇到环境变量中已经添加了相应的键值对,但是程序读取变量时值为None的问题,这时需要重启服务器来解决该问题。
10.3 不安全的随机数
10.3.1 风险描述
random模块提供的随机数生成器是伪随机数生成器。所谓伪随机数,是通过固定的算法生成的,其结果是确定的,可预见的。一般情况下,伪随机数的生成需要一个种子,如果没有特别设置,种子就是系统的时钟。简而言之,由于伪随机数算法固定,种子固定,那结果就是可推导和模拟的。所以在安全性要求较高的环境中使用该模块生成随机数时,会存在安全风险。
10.3.2 规范要求
在安全性要求较高的应用中,应使用更加安全的os.urandom()或者secrets.SystemRandom()来代替random模块生成随机数。
10.3.3 代码示例
- 使用secrets.SystemRandom()生成1-100的随机数代码如下:
secret_rand_generator = secrets.SystemRandom()
rand = secret_rand_generator.randint(1, 100)
- 如下使用os.urandom()方法生成16个随机的byte类型的序列,一般可用来生成随机的加密秘钥:
rand = os.urandom(16)