Protección anti CSRF con tokens en PHP

En este post voy a explicar cómo proteger nuestras aplicaciones PHP contra ataques de tipo CSRF. Este tipo de ataques hacen que el usuario realice acciones sobre un web de forma inadvertida. Por ejemplo, en una página vulnerable a este tipo de ataques, podrían construirse páginas de ataque en las que únicamente con la visita del usuario se modificase su información de registro, se publicase información de forma oculta, etc.

Imaginemos que estamos conectados a un web que requiere autenticación y que por ejemplo estamos manteniendo una conversación por chat con otra persona. Esta persona podría enviarnos un enlace a una página que contuviera una imagen oculta que apuntase a una url del web en el que estamos autenticado. Cuando entrásemos en esa página, la imagen oculta haría que solicitásemos al web una determinada acción sin ser conscientes de ello, por ejemplo cambiando nuestros datos de registro, publicando información en un foro a nuestro nombre, o cualquier otra cosa que se nos ocurra. El problema por tanto es que el web en el que estamos autenticado, es incapaz de saber si la petición que le llega la hemos realizado nosotros de forma voluntaria o si ha sido trampeada mediante algún ataque CSRF

Una forma de evitar esta vulnerabilidad es utilizar tokens de autorización en cada acción que se realiza sobre el servidor. Básicamente lo que hace la aplicación es generar un token de autorización y enviarlo al usuario. Cuando se envía información a la aplicación hay que enviar también el token para que el servidor lo compare con el que había generado previamente. Vamos a ver a continuación dos formas diferentes de generar estos tokens.

Token personal por acción en sesión

Esta forma de generación de tokens se basa en la creación de tokens específicos para cada tipo de acción realizable en la aplicación y el registro de estos tokens en la sesión del usuario. La siguiente función recibe como parámetro el nombre de un tipo de formulario o de acción realizable sobre el web. La función genera un token de forma aleatoria y guarda en sesión, asociado al nombre del formulario recibido, el token generado y la fecha de generación.

function generateFormToken($form) {
 
   // generar token de forma aleatoria
   $token = md5(uniqid(microtime(), true));
 
   // generar fecha de generación del token
   $token_time = time();
 
   // escribir la información del token en sesión para poder
   // comprobar su validez cuando se reciba un token desde un formulario
   $_SESSION['csrf'][$form.'_token'] = array('token'=>$token, 'time'=>$token_time);; 
 
   return $token;
}

Para incluir el token en un formulario habría que añadir un nuevo campo oculto a cada formulario que queramos securizar. En el siguiente ejemplo se ha añadido el campo «auth_token» a un formulario de envío de mensajes. En el campo «value» del campo se incluye el token obtenido indicando que se solicita un token para el formulario «send_message».

<form action="send_message.php" method="POST">
<input type="hidden" name="auth_token" value="<?=generateFormToken('send_message')?>" />
<textarea name="message-text"></textarea>
<input type="submit" />
</form>

La siguiente función permite comprobar la validez de los tickets generados. La función recibe como parámetros el nombre del formulario, el token recibido y un parámetro opcional con el tiempo de validez del ticket. Se comprueba si hay registrado en sesión un token de autorización para el formulario indicado y, si es así, se compara este token con el recibido. En caso de que sean iguales se tiene en cuenta si se indica un tiempo de validez de ticket. Este tiempo permite establecer un control complementario de forma que damos un tiempo de vida determinado a cada ticket enviado al usuario.

function verifyFormToken($form, $token, $delta_time=0) {
 
   // comprueba si hay un token registrado en sesión para el formulario
   if(!isset($_SESSION['csrf'][$form.'_token'])) {
       return false;
   }
 
   // compara el token recibido con el registrado en sesión
   if ($_SESSION['csrf'][$form.'_token']['token'] !== $token) {
       return false;
   }
 
   // si se indica un tiempo máximo de validez del ticket se compara la
   // fecha actual con la de generación del ticket
   if($delta_time > 0){
       $token_age = time() - $_SESSION['csrf'][$form.'_token']['time'];
       if($token_age >= $delta_time){
      return false;
       }
   }
 
   return true;
}

Para comprobar la validez del ticket cuando se recibe un formulario de envío de mensajes por POST, podemos utilizar el siguiente código, en el que se llama a la función anterior indicando que se quiere comprobar la validez del token recibido para el formulario «send_message» con un tiempo máximo de vida de 300 segundos (5 minutos).

$ticket = $_POST['auth_token'];
$valid = verifyFormToken('send_message', $token, 300);
if(!$valid){
   echo "El ticket recibido no es válido";
}

Con esta forma de generar los token securizamos cada formulario de la aplicación por separado. En el caso de que alguno de los tickets generados cayerá en poder de un atacante únicamente podría utilizarlo en un formulario y durante un tiempo de vida limitado. En el lado negativo hay que tener en cuenta el espacio requerido en el servidor para almacenar cada token en las sesiones de usuario y algunos problemas de usabilidad. Estos problemas pueden producirse por ejemplo si el usuario utiliza varias pestañas para navegar por el web. En este caso, podría darse el caso de tener dos pestañas abiertas con el formulario de enviar mensaje, pero cada una de ellas con un ticket diferente. Al enviar un mensaje únicamente el último ticket generado sería válido. Una forma de reducir este problema sería comprobando también la fecha de validez del ticket en la generación y no regenerándolo en cada petición de ticket.

Token personal por acción sin sesión

Esta forma de generar los tickets se basan en la utilización de una palabra secreta y el identificador de sesión del usuario. De esta forma se evita la necesidad de tener que almacenar los tokens generados en sesión. A continuación se muestran dos funciones que pueden utilizarse en lugar de las descritas en la sección anterior. Ambas funciones utilizan un valor de configuración prefijado que contiene una palabra secreta a partir de la cual se generan todos los tickets. Esto permite invalidar en cualquier momento todos los tickets modificando simplemente esta palabra.

 
$CONF['csrf_secret'] = 'dfa%d_FA{]2Ñf523scvDAgfasg';
 
public function generateFormToken($form) {
   $secret = $GLOBALS['CONF']['csrf_secret'];
   $sid = session_id();
   $token = md5($secret.$sid.$form);
   return $token;
}
 
public function verifyFormToken($form, $token) {
   $secret = $GLOBALS['CONF']['csrf_secret'];
   $sid = session_id();
   $correct = md5($secret.$sid.$form);
   return ($token == $correct);
}

La función de generación recibe el nombre del formulario a securizar y obtiene el token asociado haciendo un hash md5 sobre la concatenación de la palabra secreta, el identificador de sesión del usuario y el nombre del formulario. De esta forma se obtiene un token de autorización para cada usuario y cada acción. Para comprobar su validez se realiza la misma acción y se compara el token generado con el token recibido.

A diferencia del caso en el que registrabamos los token en sesión utilizamos menos espacio en el servidor y tenemos menos problemas de usabilidad, pero sin embargo no tenemos control sobre el tiempo de validez del ticket, estos caducan en el momento en el que finaliza la sesión de usuario. El siguiente fragmento de código permite comprobar la validez de un token recibido.

$ticket = $_POST['auth_token'];
$valid = verifyFormToken('send_message', $token);
if(!$valid){
   echo "El ticket recibido no es válido";
}
Twitter Digg Delicious Stumbleupon Technorati Facebook Email

7 Respuestas para “Protección anti CSRF con tokens en PHP”

  1. El problema que tiene la protección contra CSRF es que tienes que ir formulario por formulario, a veces quizá mezclando código PHP donde únicamente había HTML.
    No sé si conoces la funcionalidad [1] de PHP para capturar y procesar la salida antes de que le llegue al cliente. Puede usarse para procesar código HTML y meter los tokens al vuelo. Siendo entonces la protección anti CSRF un procedimiento automático.
    Por si a alguien le interesa la idea, tengo un colgado en BerliOS un pequeño proyecto [2] experimental que la implementa. Aunque no los he probado, me consta que existen proyectos similares [3].

    En «Token personal por acción sin sesión» dices «…pero sin embargo no tenemos control sobre el tiempo de validez del ticket…». Yo esto lo he solucionado empotrando el timpestamp en claro en el propio token y también como «ingrediente» del checksum. Pero no estoy seguro que sea una buena idea. Que opináis?

    Saludos.

    [1] http://php.net/manual/es/book.outcontrol.php
    [2] http://oruga.berlios.de/es
    [3] http://csrf.htmlpurifier.org

  2. Hola mmdust, en mi caso dispongo de una jerarquía de objetos que utilizo para montar los formularios, por lo que la inclusión de los tokens y su comprobación es prácticamente transparente. En mi caso prefiero utilizar una solución específica para tener un control más homogeneo en cuanto a los formularios que se crean en el lado servidor, las peticiones ajax y otras peticiones que se crean dinámicamente.

    He echado un vistazo a tu proyecto oruga y tiene buena pinta, en cuanto a lo que planteas de añadir el tiempo de validez al ticket es una buena opción, aunque sería conveniente cifrar también este timestamp utilizando por ejemplo mcrypt, para dar la menor información posible.

  3. Muy interesante, muy bien explicado y redactado. Muchas gracias por la información.

    Saludos!

  4. Gracias, pero tienses bugs en los trozos de código.

    1. No recoges bien el parametro por POST, quedaria así:
    $token = $_POST[‘auth_token’];
    $valid = verifyFormToken(‘search_home’, $token, 300);
    if(!$valid){
    echo «El ticket recibido no es válido»;
    exit();
    }

    2. La comparación para ver si ha expirado el token es incorrecta y hace lo contrario, hasta que no pasan 300 segundos no funciona. Queda, pues, así:

    function verifyFormToken($form, $token, $delta_time=0) {

    (……………………)

    if($token_age >= $delta_time){
    return false;
    }
    (……………………)

    }

  5. Patxi Echarte 07. Jun, 2012 en 4:23 pm

    Efectivamente Francesc había un fallo en la comparación del tiempo de validez del ticket. He corregido también la lectura del parámetro POST para que concuerde con el nombre que había dado al parámetro en el html de ejemplo del inicio del post.

    Espero que en cualquier caso el código te haya servido de ayuda, gracias y un saludo

  6. Me sirvió mucho, muchísimas gracias, las funciones están muy buenas!

Trackbacks/Pingbacks

  1. EsLoMas.com » Vulnerabilidades CSRF en aplicaciones web - 14. Sep, 2011

    […] Implementación de protección anti CSRF con tokens en PHP […]