伪造跨站请求

忽略名字上的相似程度,伪造跨站请求(CSRF)是几乎完全相反的攻击方式。XSS 是利用用户对网站的信任展开攻击;CSRF 是利用网站对用户的信任展开攻击。CSRF 攻击更加危险,更少遇到(意味着对于开发者没有更多资料),并且比起 XSS 攻击更加难以防御。

CSRF 攻击发生在下面的情况下:

由于 CSRF 攻击包含伪造 HTTP 请求,熟悉底层 HTTP 协议就变得非常重要。

浏览器是 HTTP 客户端,而 web 服务器是 HTTP 服务器。客户端通过发送请求初始化一个传输,而服务器通过应答完成这个传输。一个标准的 HTTP 请求如下:

        GET / HTTP/1.1
        Host: example.org
        User-Agent: Mozilla/5.0 Gecko
        Accept: text/xml, image/png, image/jpeg, image/gif, */*
    

第一行是请求行,包含请求的方式,请求的 URL(使用相对的 URL),和 HTTP 版本。其他行是 HTTP 头,每个头的名字后是一个冒号和一个空格,然后是值。

你可能熟悉使用 PHP 产生这些信息。例如,下面的代码可以用于构造这个原始的 HTTP 请求保存为字符串:

        <?php

        $request = '';
        $request .= "{$_SERVER['REQUEST_METHOD']} ";
        $request .= "{$_SERVER['REQUEST_URI']} ";
        $request .= "{$_SERVER['SERVER_PROTOCOL']}\r\n";
        $request .= "Host: {$_SERVER['HTTP_HOST']}\r\n";
        $request .= "User-Agent: {$_SERVER['HTTP_USER_AGENT']}\r\n";
        $request .= "Accept: {$_SERVER['HTTP_ACCEPT']}\r\n\r\n";

        ?>
    

响应前面的请求的应答如下:

        HTTP/1.1 200 OK
        Content-Type: text/html
        Content-Length: 57

        <html>
            <img src="http://example.org/image.png" />
        </html>
    

应答的内容就是你在浏览器中查看代码时看到的。这个应答中的 img 标签告诉浏览器取得另外一个资源(图象)并呈现在页面上。浏览器请求这个资源以及其他,下面这个例子关于这个请求:

        GET /image.png HTTP/1.1
        Host: example.org
        User-Agent: Mozilla/5.0 Gecko
        Accept: text/xml, image/png, image/jpeg, image/gif, */*
    

这里值得注意。浏览器请求在 img 标签的 src 属性指定的 URL,就如同用户手工定向到那里。浏览器无法明确指出请求的是一个图象。

将这个同之前了解的表单联系在一起,并且考虑一个如同下面的 URL :

        http://stocks.example.org/buy.php?symbol=SCOX&quantity=1000
    

一个表单使用 GET 提交是无法同图象请求区分开的——两个都可以用相同的 URL 请求。如果 register_globals 开启,表单操作就不十分重要了(除非开发者使用 $_POST 以及相关)。危险似乎已经变得清晰了。

另外一个情况使得 CSRF 如此严重的原因是任何 URL 的 cookie 都是包含在对该 URL 的请求中。 一个准备同 stocks.example.org 建立联系的用户(比如已经登录)可以通过访问如同前面示例中的含有 img 标签的页面来购买 1000 份的 SCOX。 考虑下面这个表单(假想的): http://stocks.example.org/form.html

        <p>立刻购买!</p>
        <form action="/buy.php">
            <p>代码:<input type="text" name="symbol" /></p>
            <p>数量:<input type="text" name="quantity" /></p>
            <input type="submit" />
        </form>
    

如果用户输入 SCOX 作为代码,1000 作为数量,并且提交表单,浏览器发送的请求如下:

        GET /buy.php?symbol=SCOX&quantity=1000 HTTP/1.1
        Host: stocks.example.org
        User-Agent: Mozilla/5.0 Gecko
        Accept: text/xml, image/png, image/jpeg, image/gif, */*
        Cookie: PHPSESSID=1234
    

在本例中包含了 Cookie 头用于说明使用 cookie 作为 session 标识。如果一个 img 标签指向一同一个 URL,请求这个 URL 的时候相同的 cookie 也会被发送,服务器处理这个请求时无法区分是不是真正的订单。

有一些可以保护应用不受 CSRF 攻击的办法:

现在我们可以编写更加安全的留言版:

        <?php

        $token = md5(time());

        $fp = fopen('./tokens.txt', 'a');
        fwrite($fp, "$token\n");
        fclose($fp);

        ?>

        <form method="POST">
            <input type="hidden" name="token" value="<?php echo $token; ?>" />
            <input type="text" name="message"><br />
                <input type="submit">
                </form>

                <?php

                $tokens = file('./tokens.txt');

                if (in_array($_POST['token'], $tokens))
                {
                if (isset($_POST['message']))
                {
                $message = htmlentities($_POST['message']);

                $fp = fopen('./messages.txt', 'a');
                fwrite($fp, "$message<br />");
                fclose($fp);
                }
                }

                readfile('./messages.txt');

                ?>
            

这个留言版仍然有一些安全问题。你能否发现它们?

使用时间极为容易预测。对时间戳进行 MD5 散列是很简陋的生成随机数码的办法。更好的方案包含 uniqid()rand()

更重要的是,攻击者很容易就可获得合法令牌(token)。只需要访问合法令牌产生并存储的文件,只要在请求中添加令牌,攻击像以前一样简单。

这里是改进的留言版:

                <?php

                session_start();

                if (isset($_POST['message']) && isset($_SESSION['token']))
                {
                if (isset($_SESSION['token']) && $_POST['token'] == $_SESSION['token'])
                {
                $message = htmlentities($_POST['message']);

                $fp = fopen('./messages.txt', 'a');
                fwrite($fp, "$message<br />");
                fclose($fp);
                }
                }

                $token = md5(uniqid(rand(), true));
                $_SESSION['token'] = $token;

                ?>

                <form method="POST">
                    <input type="hidden" name="token" value="<?php echo $token; ?>" />
                    <input type="text" name="message"><br />
                        <input type="submit">
                        </form>

                        <?php

                        readfile('./messages.txt');

                        ?>