This question got me thinking: How do we explain and use form tokens? What do they do for us and what do they not do? While they have been considered among security "best practices" for many years, it's important to understand the benefits and limitations. This article shows the design pattern for form tokens, explains the uses and limitations, and teaches a stronger approach to tokenization -- one that not only addresses Cross-Site-Request Forgeries, but also mitigates the risk of "screen scrapers" that would simulate a client browser in order to steal online information.
"...nothing provided in an HTTP request can be trusted." -- Chris Shiflett, circa 2005
<?php // lame_form_token_client.php
/**
* A client side script that creates a form token and saves the token in the PHP session
* The script also injects the form token into a hidden POST request variable
* When the form is submitted, the script tests the token to see if POST matches SESSION
*/
error_reporting(E_ALL);
session_start();
// FUNCTION TO EVALUATE THE IDENTITY IN THE FORM
function check_form_token()
{
$sess_token = !empty($_SESSION['form_token']) ? $_SESSION['form_token'] : 'X';
$post_token = !empty($_POST['form_token']) ? $_POST['form_token'] : 'Y';
$_SESSION['form_token'] = NULL;
if ($sess_token == $post_token) return TRUE;
return FALSE;
}
// IF THERE IS A POST-REQUEST
if (!empty($_POST))
{
$status = check_form_token();
if (!$status) echo "Attack! Run like hell!";
if ( $status) echo "Success! Trust this client.";
exit;
}
// CREATE RANDOM FORM TOKEN, SAVED IN THE SESSION, INJECTED INTO THE HTML
$token = md5( rand() );
$_SESSION['form_token'] = $token;
$html = <<<EOF
<!DOCTYPE html>
<html dir="ltr" lang="en-US">
<head>
<meta charset="utf-8" />
<title>A Lame Form Token Example</title>
</head>
<body>
<form name="my_form" method="post">
<input type="submit" value="Verify Token" />
<input type="hidden" name="form_token" value="$token" />
</form>
</body>
</html>
EOF;
echo $html;
This demonstration is entirely self-contained. You can copy this script and install it on your own server, and run it to see how it behaves. After trying it once and clicking the Verify Token button, try refreshing the browser, effectively resubmitting the form but without re-requesting the form. The form token in the HTML will not match the value in the PHP session, and the script will recognize an attack.
<?php // lame_form_token_scraper.php
/**
* This script scrapes a form token and injects it into the request variables.
* It can successfully attack any web page that has clear text input controls,
* including hidden controls. It reads the HTML form and creates the HTTP request
* as if it were a human being using a web browser.
*/
error_reporting(E_ALL);
// START WITH A GET-METHOD REQUEST TO THE VICTIM URL
$url = 'https://iconoun.com/demo/lame_form_token_client.php';
// SET UP A CURL WORKER
$curl = curl_init();
// HEADERS AND OPTIONS APPEAR TO BE A FIREFOX BROWSER REFERRED BY GOOGLE
$header[] = "Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5";
$header[] = "Cache-Control: max-age=0";
$header[] = "Connection: keep-alive";
$header[] = "Keep-Alive: 300";
$header[] = "Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7";
$header[] = "Accept-Language: en-us,en;q=0.5";
$header[] = "Pragma: "; // BROWSERS USUALLY LEAVE THIS BLANK
// SET THE CURL OPTIONS - SEE http://php.net/manual/en/function.curl-setopt.php
curl_setopt( $curl, CURLOPT_URL, $url );
curl_setopt( $curl, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 6.1; rv:44.0) Gecko/20100101 Firefox/44.0' );
curl_setopt( $curl, CURLOPT_HTTPHEADER, $header );
curl_setopt( $curl, CURLOPT_REFERER, 'http://www.google.com' );
curl_setopt( $curl, CURLOPT_ENCODING, 'gzip,deflate' );
curl_setopt( $curl, CURLOPT_AUTOREFERER, TRUE );
curl_setopt( $curl, CURLOPT_RETURNTRANSFER, TRUE );
curl_setopt( $curl, CURLOPT_FOLLOWLOCATION, TRUE );
curl_setopt( $curl, CURLOPT_TIMEOUT, 3 );
curl_setopt( $curl, CURLOPT_VERBOSE, TRUE );
curl_setopt( $curl, CURLOPT_FAILONERROR, TRUE );
// SET THE LOCATION OF THE COOKIE JAR (THIS FILE WILL BE OVERWRITTEN)
curl_setopt( $curl, CURLOPT_COOKIEFILE, 'lame_cookie.txt' );
curl_setopt( $curl, CURLOPT_COOKIEJAR, 'lame_cookie.txt' );
// IF USING SSL, THIS MAY BE IMPORTANT
curl_setopt( $curl, CURLOPT_SSL_VERIFYHOST, FALSE );
curl_setopt( $curl, CURLOPT_SSL_VERIFYPEER, FALSE );
// RUN THE CURL REQUEST AND GET THE RESULTS
$document = curl_exec($curl);
// EXTRACT THE NAMED INPUT CONTROLS
$tags = strip_tags($document, '<input>');
$tags = explode(PHP_EOL, $tags);
foreach ($tags as $key => $tag)
{
if (!stripos($tag, 'name=')) unset($tags[$key]);
}
// SIMPLIFIED EXAMPLE: EXTRACT THE NAME AND VALUE PAIRS
$name = $valu = NULL;
foreach ($tags as $tag)
{
$name = explode('name="', $tag);
$name = $name[1];
$name = explode('"', $name);
$name = $name[0];
}
foreach ($tags as $tag)
{
$valu = explode('value="', $tag);
$valu = $valu[1];
$valu = explode('"', $valu);
$valu = $valu[0];
}
// CREATE A REQUEST STRING
$vars = $name . '=' . htmlspecialchars($valu);
// TURN THE REQUEST AROUND TO POST THE FORM DATA
curl_setopt( $curl, CURLOPT_REFERER, $url );
curl_setopt( $curl, CURLOPT_POST, TRUE );
curl_setopt( $curl, CURLOPT_POSTFIELDS, $vars );
// CALL THE WEB PAGE
$xyz = curl_exec($curl);
$err = curl_errno($curl);
$inf = curl_getinfo($curl);
// IF ERRORS - SEE http://curl.haxx.se/libcurl/c/libcurl-errors.html
if ($xyz === FALSE)
{
echo PHP_EOL . "CURL POST FAIL: $url CURL_ERRNO=$err ";
var_dump($inf);
}
// SHOW WHAT CAME BACK FROM THE POST
echo PHP_EOL . htmlentities($xyz);
// SHOW THE FORM TOKEN WE GOT FROM THE FORM
echo PHP_EOL . $vars;
Again, this script is entirely self-contained. You can copy this script and use it to attack your copy of the lame form token script. In practice, there would be more involved code needed to mount a real attack, but all of the basic principles are shown here, and it clearly illustrates the exposure created by a clear-text form value, even if the input type is "hidden."
<?php // form_token_class.php
/**
* A helper class for form token processing
*
* Method get() returns a form token object
* Method tidy() removes expired tokens
* Method check() verifies that a token is valid
*/
error_reporting(E_ALL);
// A CLASS TO DEFINE OUR FORM TOKEN
Class FormToken
{
const FORM_TOKEN_PREFIX = 'form_token_';
const FORM_TOKEN_EXPIRY = 300;
public static function get()
{
$obj = new StdClass;
$obj->time = time();
if (function_exists('random_bytes')) // CRYPTO-SECURE
{
$obj->name = static::FORM_TOKEN_PREFIX . bin2hex( random_bytes(32) );
$obj->token = static::FORM_TOKEN_PREFIX . bin2hex( random_bytes(32) );
}
else // FALL-BACK FOR PHP < 7
{
$obj->name = static::FORM_TOKEN_PREFIX . md5( uniqid() . rand() );
$obj->token = static::FORM_TOKEN_PREFIX . md5( uniqid() . rand() );
}
return $obj;
}
public static function tidy()
{
$timex = time() - static::FORM_TOKEN_EXPIRY;
$prefix_length = strlen(static::FORM_TOKEN_PREFIX);
foreach ($_SESSION as $key => $value)
{
if (substr($key,0,$prefix_length) == static::FORM_TOKEN_PREFIX)
{
if ($token = json_decode($value))
{
if (!empty($token->time) && ($token->time < $timex)) unset($_SESSION[$key]);
}
}
}
}
public static function check()
{
static::tidy(); // REMOVES EXPIRED TOKENS
$regex = '#' . preg_quote($_SERVER['HTTP_HOST']) . '#i';
if (!preg_match($regex, $_SERVER['HTTP_REFERER'])) return FALSE; // RUDIMENTARY SAME-ORIGIN CHECK
$prefix_length = strlen(static::FORM_TOKEN_PREFIX);
foreach ($_SESSION as $key => $value)
{
if (substr($key,0,$prefix_length) == static::FORM_TOKEN_PREFIX)
{
if ($session_token_obj = json_decode($value))
{
if (!empty($_POST[$session_token_obj->name]) && ($_POST[$session_token_obj->name] == $session_token_obj->token))
{
unset($_SESSION[$key]); // MAKES EACH TOKEN INTO A SINGLE-USE TOKEN
return TRUE;
}
}
}
}
return FALSE;
}
}
<?php // form_token_server.php
/**
* A server side script that responds to an AJAX request
* This script gets a form token object and encodes it into a JSON string
* It stores the JSON string in the PHP session and echos it to the client
*/
error_reporting(E_ALL);
require_once('form_token_class.php');
session_start();
// GET, SAVE, AND RETURN A NEW FORM TOKEN OBJECT
$token = FormToken::get();
$_SESSION[$token->name] = json_encode($token);
session_write_close();
echo $_SESSION[$token->name];
<?php // form_token_client.php
/**
* A client side script that creates an AJAX request for a form token
* This script injects the form token into the request variables
*/
error_reporting(E_ALL);
require_once('form_token_class.php');
session_start();
// IF THERE IS A POST-REQUEST
if (!empty($_POST))
{
$status = FormToken::check();
if (!$status) echo "Attack! Run like hell!";
if ( $status) echo "Success! Trust this client.";
exit;
}
$html = <<<EOF
<!DOCTYPE html>
<html dir="ltr" lang="en-US">
<head>
<meta charset="utf-8" />
<title>A Variable Form Token Example</title>
<script type="text/javascript" src="https://code.jquery.com/jquery-latest.min.js"></script>
<script>
$(document).ready(function(){
$.get("form_token_server.php", function(response){
var json = JSON.parse(response);
var myForm = document.forms['my_form'];
var input = document.createElement('input');
input.type = 'hidden';
input.name = json.name;
input.value = json.token;
myForm.appendChild(input);
});
});
</script>
</head>
<body>
<form name="my_form" method="post">
<input type="submit" value="Verify Token" />
</form>
</body>
</html>
EOF;
echo $html;
Have a question about something in this article? You can receive help directly from the article author. Sign up for a free trial to get started.
Comments (1)
Commented:
Thank you very much Ray Paseur