miércoles, 28 de mayo de 2008

Creación de un CAPTCHA (o test de turing)

A esta altura, la mayoría de nosotros nos hemos encontrado en situaciones que cuando ingresamos información en un formulario para registrarnos en algún servicio -como por ejemplo crear una casilla de correo o descargar un archivo de servicios como rapidshare- el sitio nos pide que escribamos el texto de una imagen que vemos en pantalla.


Estos mecanismos de validación permiten -en teoría- distinguir entre humanos y computadores, y así prevenir abusos como por ejemplo la publicación de spams en los comentarios de blogs y la creación masiva de casillas de correo para spamming.

En los últimos años han proliferado diversos mecanismos de CAPTCHA (Completely Automated Public Turing test to tell Computers and Humans Apart), como por ejemplo resolver un problema matemático, indicar el animal que ves es una fotografía o -el más popular- identificar el texto que ves en una imágen.

Una de las desventajas de los CAPTCHAs es que tienen serias deficiencias de accesibilidad. Además existen algunos casos extremadamente molestos para el usuario como el siguiente ejemplo utilizado en Rapidshare:

Debido a que la mayoria de los desarrollos de software en que he participado son sistemas cerrados o que no son un potencial blanco de ataques de spamming, no me he visto en la necesidad de implementar mecanismos de CAPTCHA, sin embargo, haciendo algunos experimentos he llegado a implementar un sistema sencillo en PHP que les describo a continuación.


Implementación de CAPTCHA en PHP
En este ejercicio voy a generar un CAPTCHA que le presente al usuario una imagen con letras que el usuario deberá escribir en un formulario. Debido al uso de imágenes, me apoyaré en la biblioteca GD, por lo que para estos ejemplos PHP debe tener soporte para GD.

Mi objetivo es generar un captcha que nos brinde un nivel de seguridad mínima y en ningún caso desarrollar un sistema infalible. Detrás de la generación (y decodificación) de los captchas existen teorías de procesamiento de imágenes y reconocimiento de caracteres que no son parte de este artículo.

El modelo general consiste en construir un script PHP que retorne una imagen con un texto aleatorio. El texto original será almacenado en una variable de sesión. Al procesar el formulario se deberá comparar el texto ingresado por el usuario con el texto almacenado en la variable de sesión.


Versión 1
Mi primera versión consiste en una implementación básica sólo para desarrollar la funcionalidad mínima del sistema:
<?php

// Alto y ancho de la imagen
$width = 250;
$height = 60;
// Tipografia
$font = "arial.ttf";
// Nombre de la variable de sesion
$session_var = "captcha";




// Genero la imagen
$im = imagecreatetruecolor($width, $height);
$bg_color = imagecolorallocate($im, 255, 255, 255);
$fg_color = imagecolorallocate($im, 0, 0, 0);
imagefilledrectangle($im, 0, 0, $width, $height, $bg_color);

// Genero el texto
$text = "secret";
imagettftext($im, 20, 0, 11, 21, $fg_color, $font, $text);

// Guardo el texto en sesión
session_start();
$_SESSION[$session_var] = $text;


// Genero la imagen
header("Content-type: image/png");
imagepng($im);

// Limpieza
imagedestroy($im);

?>

Este script genera una imagen de 250x60 pixeles, con color de fondo blango y texto arial negro. El texto utilizado en la imagen se guarda en la variable de sesión captcha. El resultado de este script es la siguiente imagen:

En el script que procesa el formulario, basta comparar el valor de $_SESSION['captcha'] con el texto ingresado en el formulario para confirmar que el captcha haya sido ingresado satisfactoriamente. Para reducir la tasa de error, es posible quitar los eventuales espacios en blanco y convertir el texto a minúculas para hacer una comparación case-insensitive.

Ahora que contamos con el código base podemos continuar mejorando nuestro script.


Versión 2
Las mejoras que introduciremos en esta versión son las siguientes:
  1. Generación de un texto aleatorio.
  2. Generar texto con distintas tipografías.
  3. Agregar transformaciones simples al texto.
  4. Aumentar el tamaño y darle un color más amigable.

La generación de un texto aleatorio puede ser algo tan sencillo como extraer los primeros 6 caracteres de un valor MD5 como el siguiente: substr(md5(uniqid(),0,6) el cual genera un string con números y letras entre la "a" y la "f".

Sin embargo construiremos algo más elaborado donde podamos controlar los caracteres a generar e intentaremos que el texto sea medianamente pronunciable con el fin de ser más amigable con el usuario. Una manera simple de generar texto pronunciable es asegurándonos de tener suficientes vocales y quitando algunas letras poco usadas en el español como la "x" y la "k". Definí arbitrariamente la longitud del texto a 6 caracteres lo cual minimiza la generación de palabras mal vistas como "pene", "puta" o "malo" y da una cantidad de combinaciones de letras apropiada.

Para ello elaboré una función que retorna textos aleatorios intercalando una consonante y una vocal:
<?php

function getCaptchaText($length = 6) {
$consonants = "bcdfghjlmnpqrstvwyz";
$vocals = "aeiou";

$text = "";
$imax = $length/2;
for ($i=0; $i<$imax; $i++) {
$text .= substr($consonants, mt_rand(0, 18), 1);
$text .= substr($vocals, mt_rand(0, 4), 1);
}
return substr($text, 0, $length);
}

?>
Por lo tanto, en el script original reemplazamos:
$text = "secret";
por:
$text = getCaptchaText(6);
Con este cambio ahora generamos captchas como los siguientes:



Para hacer el captcha más complejo vamos a generar el texto con distintas tipografías, agregarle rotación independiente a cada letra y juntar (o condensar) las letras para que se produzca un traslape que hará más dificil su decodificación.

Extraje algunos archivos .ttf desde mi directorio C:\WINDOWS\Fonts y luego reemplacé:
$font = "arial.ttf";
por:
$fonts = array('timesbi.ttf', 'calibriz.ttf', 'cambriaz.ttf');
$font = $fonts[mt_rand(0, sizeof($fonts)-1)];
Aumentamos el tamaño, le agregamos color y ahora obtenemos las siguientes imagenes donde podemos apreciar el cambio de tipografía en cada ejecución:

Hasta este punto hemos generado un captcha básico que es extremadamente fácil de decodificar.

Para hacer la imágen mas compleja vamos a generar letra por letra. Cada letra tendrá una rotación distinta y acercaremos las letras para lograr un traslape.

Modificamos el código:
imagettftext($im, 20, 0, 11, 21, $fg_color, $font, $text);
por:
$x = 3;
for ($i=0; $i<=6; $i++) {
$angulo = rand(-12, 12);
$coords = imagettftext($im, 38, $angulo, $x, 47, $fg_color, $font, substr($text, $i, 1));
$x += ($coords[2]-$x) - 4;
}

Este código va generando letra por letra y en cada iteración va aumentando la coordenada X restandole algunos pixeles para lograr que el texto se traslape. La rotación de cada letra tendrá un valor comprendido entre el intervalo [-12,12].

Se deberá buscar el valor exacto de traslape con el fin de hacer difícil su decodificación, pero permitiendo que el texto sea legible. Un refinamiento de la generación de estos textos podría ser que cada letra se genere con una tipografía distinta.

Al aplicar estos cambios obtenemos las siguientes imagenes:


Una vez que juntemos todos estos cambios obtendremos el siguiente código:

<php


// Alto y ancho de la imagen
$width = 250;
$height = 65;
// Tipografia
$fonts = array('timesbi.ttf', 'calibriz.ttf', 'cambriaz.ttf');
// Nombre de la variable de sesion
$session_var = "captcha";
// Condensacion entre cada letra
$condensacion = 4;


// Genero la imagen
$im = imagecreatetruecolor($width, $height);
$bg_color = imagecolorallocate($im, 255, 255, 255);
$fg_color = imagecolorallocate($im, 33, 67, 165);
imagefilledrectangle($im, 0, 0, $width, $height, $bg_color);

// Genero el texto
$font = $fonts[mt_rand(0, sizeof($fonts)-1)];
$text = getCaptchaText(6);
$x = 3;
for ($i=0; $i<=6; $i++) {
$angulo = rand(-12, 12);
$coords = imagettftext($im, 38, $angulo, $x, 47, $fg_color, $font, substr($text, $i, 1));
$x += ($coords[2]-$x)-$condensacion;
}

// Guardo el texto en sesión
session_start();
$_SESSION[$session_var] = $text;

// Genero la imagen
header("Content-type: image/png");
imagepng($im);

// Limpieza
imagedestroy($im);

/**
* Retorna un texto aleatorio
*
* @param int $length Longitud del texto
* @return string Texto aleatorio
*/
function getCaptchaText($length = 6) {
$consonants = "bcdfghjlmnpqrstvwyz";
$vocals = "aeiou";
$text = "";
$imax = $length/2;
for ($i=0; $i<$imax; $i++) {
$text .= substr($consonants, mt_rand(0, 18), 1);
$text .= substr($vocals, mt_rand(0, 4), 1);
}
return substr($text, 0, $length);
}

?>



Versión 3
Para dificultar aún más la decodificación le aplicaremos a la imagen un filtro "wave" que genere ondulaciones al texto.

Adicionalmente para aumentar el "ruido" de la imagen la generaré como JPEG en vez de PNG.

Existen otras técnicas bastante eficientes para la mayoría de los casos que consiste en introducir imágenes de fondo y ruido a la imágen como por ejemplo líneas aleatorias o pintar pixeles al alzar. Sin embargo dichas técnicas disminuyen en gran medida la estética del captcha por lo que las descartaré para estos ejemplos.

Para la generación de ondas en la imágen, existen excelentes biblitecas gráficas que permiten hacer esto como por ejemplo ImageMagick. Sin embargo, para continuar con la simplicidad del script utilizaremos una combinación de funciones de GD y funciones trigonométricas. La función de a continuación está basada en un ejemplo publicado en el manual de PHP:

/**
* Filtro wave
*
* Obtenido desde ejemplo publicado en el manual de PHP:
* http://www.php.net/manual/en/function.imagecopy.php#72393
*
*/
function wave_region($img, $x, $y, $width, $height,$amplitude = 4.5,$period = 30) {
// Make a copy of the image twice the size
$mult = 2;
$img2 = imagecreatetruecolor($width * $mult, $height * $mult);
imagecopyresampled ($img2,$img,0,0,$x,$y,$width * $mult,$height * $mult,$width, $height);

// Wave it
$k = rand(-60,60);
for ($i = 0;$i < ($width * $mult);$i += 2) {
imagecopy($img2,$img2,
$x + $i - 2,$y + sin($k+$i / $period) * $amplitude,
$x + $i,$y,
2,($height * $mult));
}

// Resample it down again
imagecopyresampled ($img,$img2,$x,$y,0,0,$width, $height,$width * $mult,$height * $mult);
imagedestroy($img2);
}


Esta función la llamaremos inmediatamente después de generar el texto con los siguientes argumentos:
// Aplico filtros
wave_region($im, 0, 0, $width, $height, mt_rand(7, 22), mt_rand(25,40));
Una vez que apliquemos este cambio se generarán las siguientes imágenes de referencia:


Al juntar estos últimos cambios y ajustar el tamaño definitivo de la imágen obtendremos el siguiente código:

<php



// Alto y ancho de la imagen
$width = 180;
$height = 65;
// Tipografia
$fonts = array('timesbi.ttf', 'calibriz.ttf', 'cambriaz.ttf');
// Nombre de la variable de sesion
$session_var = "captcha";
// Condensacion entre cada letra
$condensacion = 4;



// Genero la imagen
$im = imagecreatetruecolor($width, $height);
$bg_color = imagecolorallocate($im, 255, 255, 255);
$fg_color = imagecolorallocate($im, 33, 67, 165);
imagefilledrectangle($im, 0, 0, $width, $height, $bg_color);

// Genero el texto
$font = $fonts[mt_rand(0, sizeof($fonts)-1)];
$text = getCaptchaText(6);

//$text = "captcha"; $font="cambriaz.ttf"; $angulo = 0;

$x = 3;
for ($i=0; $i<=6; $i++) {
$angulo = rand(-12, 12);
$coords = imagettftext($im, 38, $angulo, $x, 47, $fg_color, $font, substr($text, $i, 1));
$x += ($coords[2]-$x)-$condensacion;
}

// Aplico filtros
wave_region($im, 0, 0, $width, $height, mt_rand(7, 22), mt_rand(25,40));

// Guardo el texto en sesión
session_start();
$_SESSION[$session_var] = $text;

// Genero la imagen
header("Content-type: image/jpeg");
imagejpeg($im);

// Limpieza
imagedestroy($im);

/**
* Retorna un texto aleatorio
*
* @param int $length Longitud del texto
* @return string Texto aleatorio
*/
function getCaptchaText($length = 6) {
$consonants = "bcdfghjlmnpqrstvwyz";
$vocals = "aeiou";
$text = "";
$imax = $length/2;
for ($i=0; $i<$imax; $i++) {
$text .= substr($consonants, mt_rand(0, 18), 1);
$text .= substr($vocals, mt_rand(0, 4), 1);
}
return substr($text, 0, $length);
}

/**
* Filtro wave
*
* Obtenido desde ejemplo publicado en el manual de PHP:
* http://www.php.net/manual/en/function.imagecopy.php#72393
*
*/
function wave_region($img, $x, $y, $width, $height,$amplitude = 4.5,$period = 30) {
// Make a copy of the image twice the size
$mult = 2;
$img2 = imagecreatetruecolor($width * $mult, $height * $mult);
imagecopyresampled ($img2,$img,0,0,$x,$y,$width * $mult,$height * $mult,$width, $height);

// Wave it
$k = rand(-60,60);
for ($i = 0;$i < ($width * $mult);$i += 2) {
imagecopy($img2,$img2,
$x + $i - 2,$y + sin($k+$i / $period) * $amplitude,
$x + $i,$y,
2,($height * $mult));
}

// Resample it down again
imagecopyresampled ($img,$img2,$x,$y,0,0,$width, $height,$width * $mult,$height * $mult);
imagedestroy($img2);
}

?>

La evolución del captcha construido fue la siguiente:

Como mejoras a este captcha se podría realizar una seleccón mas exuastiva de las tipografías utilizadas, se le podría añadir una imagen de fondo aleatoria, lineas aleatorias o ruido.

En el siguiente artículo vamos a optimizar el código y aumentar la complejidad del captcha sin perder su estética (es decir no le incorporaré lineas aleatorias, imágenes de fondo, etc.) de una manera similar a los captchas utilizados por Google.

Actualizado el día domingo 1 de junio de 2008.

2 comentarios:

Anónimo dijo...

Estimado José,
me extraña mucho que nadie haya comentado sobre tu artículo. Leo mucho y pocas veces veo algo bien redactado y comprensible. Te felicito. Un artículo simple, didactico y sobre un tema bien útil, sobretodo para los que diseñamos sobre web.
Gracias pro tu aporte.

Anónimo dijo...

No funciona en mi servidor. Solo se ve un icono en la imagen. ¿Que mas hay que instalar? Ya tengo gd.