Fabian Martinez Portantier

FMP

Experto en Ciberseguridad
Co-Fundador de Securetia
Co-Fundador de h4ck3d Security Conference
Coordinador Carrera Seguridad Informática en EducacionIT

Ethical Hacking + Python II - XOR

hacking python

Vamos a analizar cómo implementar el cifrado XOR en Python, para extender las funcionalidades del proyecto Habu https://github.com/portantier/habu.

Introducción

Como bien lo explica la Wikipedia, 'XOR' puede referirse a varias cosas (todas ellas muy relacionadas entre sí):

  • La puerta XOR, en electrónica.
  • El operador XOR, en lógica, matemáticas y programación.
  • El cifrado XOR

El 'o exclusivo' (eXclusive OR) es un operador lógico simbolizado como XOR, EOR, EXOR, ⊻, o ⊕ es un tipo de disyunción lógica de dos operandos.

Nota: El operador XOR en python es ^

Una disyunción solamente es verdadera cuando ambas partes tienen valores diferentes de verdad, es decir, cuando una u otra es verdadero. No en el caso de que ambos sean verdaderos o ambos sean falsos.

Por eso, siempre que hagamos un XOR de un valor consigo mismo vamos a obtener falso (A ^ A = False)

Observemos los siguientes ejemplos:

  • True ^ True = False
  • True ^ False = True
  • False ^ True = True
  • False ^ False = False

Lo mismo, pero en python:

$ python
Python 3.5.2 (default, Jun 28 2016, 08:46:01) 
[GCC 6.1.1 20160602] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> True ^ True
False
>>> True ^ False
True
>>> False ^ True
True
>>> False ^ False
False

NOTA: Aquí estamos utilizando Python en modo interactivo. A partir de ahora, los ejemplos en modo interactivo van a estar marcados por el prompt ">>>" y voy a omitir todos los demás mensajes de versión y ayuda para hacer el código más breve.

Aplicaciones de XOR

XOR tiene una propiedad que la hace ser muy importante para el uso en la criptografía:

"La doble aplicación de XOR resulta en la identidad"

Esto quiere decir que si aplicamos la función XOR a un valor, y luego se la volvemos a aplicar, obtenemos el valor original, lo podemos explicar así:

(A ^ B) ^ B = A

En Python:

>>> (True ^ True) ^ True
True
>>> (True ^ False) ^ False
True
>>> (False ^ True) ^ True
False
>>> (False ^ False) ^ False
False

Viendo esto, podemos determinar que, si tenemos una secuencia de valores True o False, que vamos a llamar "A", y otra secuencia de valores que vamos a llamar "B", y a "A" le aplicamos XOR utilizando B, obtenemos una tercer secuencia de valores, que llamaremos "C". Si a "C" le aplicamos XOR con "B", volvemos a obtener "A".

¿Qué es lo importante de esto? Acabamos de cifrar los datos.

Con una función tan básica como XOR, y una clave, podemos empezar a cifrar datos.

Siempre debemos recordar que todos los datos, en las computadoras, terminan siendo representados por un 0 o por un 1.

(Muchas veces vamos a ver el mapeo de True = 1, False = 0)

Creando la Función XOR

Vamos a desarrollar, paso a paso, la función XOR que luego vamos a implementar como un comando de Habu.

Para eso, primero vamos a introducir la función os.urandom(), que sirve para solicitar una n cantidad de datos aleatorios a nuestro sistema operativo. https://docs.python.org/3/library/os.html#os.urandom

>>> import os
>>> A = os.urandom(8)
>>> for x in A:
...     print(x)
... 
216
122
245
149
246
94
189
27

En el código anterior asignamos a 'A' 8 bytes aleatorios. Luego recorremos los 8 bytes y mostramos cada uno con la función 'print'.

Cada byte aleatorio va a tener un valor entre 0 y 255, porque un byte son 8 bits, y con 8 bits podemos representar valores desde el 0 al 255.

NOTA: Como a continuación vamos a utilizar una función llamada 'zip()', primero vamos a explicar para qué sirve.

La función zip permite agregar varios iterables, devolviendo un iterable de tuplas, en los que cada elemento i de la tupla se corresponde con los elementos i de cada uno de los iterables que se pasaron como parámetro.

Un ejemplo:

>>> A = ['pera', 'manzana', 'limon']
>>> B = ['verde', 'roja', 'amarillo']
>>>
>>> list(zip(A,B))
[('pera', 'verde'), ('manzana', 'roja'), ('limon', 'amarillo')]

Como detalle, tenemos que marcar que estoy convirtiendo el resultado de zip a una lista, para poder mostrarlo más facilmente con la función print.

Ahora vamos a hacer el ejercicio de trabajar con los valores A, B y C. Teniendo el valor A, lo vamos a XORear con B, para obtener C. Al valor C, lo vamos a XORear con B, y obtendremos el valor A.

>>> import os
>>> 
>>> A = os.urandom(8)
>>> B = os.urandom(8)
>>> 
>>> list(A)
[151, 82, 79, 150, 128, 52, 105, 103]
>>> 
>>> list(B)
[166, 90, 35, 99, 7, 42, 171, 28]
>>> 
>>> C = bytes([ a^b for a,b in zip(A,B) ])
>>> 
>>> list(C)
[49, 8, 108, 245, 135, 30, 194, 123]
>>> 
>>> D = bytes([ c^b for c,b in zip(C,B) ])
>>> 
>>> list(D)
[151, 82, 79, 150, 128, 52, 105, 103]
>>>
>>> A == D
True

Acabamos de hacer muchas cosas en pocas líneas, que ahora vamos a pasar a explicar...

Primero, creamos las variables A y B, con 8 bytes aleatorios cada uno.

>>> A = os.urandom(8)
>>> B = os.urandom(8)

Mostramos los valores de A y B, convirtiéndolos en listas con la función list().

>>> list(A)
[151, 82, 79, 150, 128, 52, 105, 103]
>>> 
>>> list(B)
[166, 90, 35, 99, 7, 42, 171, 28]

Creamos C, que se calcula de la siguiente forma:

Con zip(), generamos un iterador con un tupla para cada elemento de A,B. Por ejemplo, la primer tupla generada va a ser (151, 166).

Para cada tupla de ese iterador, asignamos los valores en 'a' y 'b', es decir, en la primera iteración sobre el iterador, el valor de 'a' = 151 y el de 'b' = 166.

Aplicamos la función XOR sobre los valores de 'a' y 'b' (a^b).

Generamos una lista con todos los valores obtenidos de las iteraciones.

Esa lista, la convertimos al tipo de dato 'bytes', para que tenga el mismo tipo de dato que A y B, con el propósito de poder comprarlos más adelante.

>>> C = bytes([ a^b for a,b in zip(A,B) ])

Con 'list(C)' lo que hicimos fue mostrar por pantalla una representación de C en formato de lista, porque si mostramos el dato en bytes, se va a ver algo así:

b'1\x08l\xf5\x87\x1e\xc2{'

Llegado a este punto, ya obtuvimos el resultado de XORear A con B, y lo almacenamos en la variable C.

Ahora, vamos a repetir el proceso, XOReando C y B, y almacenando el resultado en la variable D.

>>> D = bytes([ c^b for c,b in zip(C,B) ])

Anteriormente dijimos que: (A ^ B) ^ B = A

Entonces, si XOReamos C con B, deberíamos obtener el valor de A.

Con 'A == D', verificamos que A y D tengan el mismo valor y, como podemos ver, el resultado de la comparación fue True, por lo que, efectivamente, obtuvimos en D el valor de A.

>>> A == D
True

Acabamos de cifrar el valor de A con la clave B, para obtener C. Luego desciframos C con la clave B, y volvimos a obtener el valor de A.

Datos y Claves de Diferente Longitud

En el ejemplo anterior, A y B tenían la misma longitud (8 bytes) y eso le permitía a la función zip() generar un iterable con 8 tuplas (una por cada byte).

Hay un detalle, y es que, si la función zip() encuentra que los dos o más iterables que se le pasan como parámetro, tienen distinta longitud, va a devolver un iterable del tamaño del menor de los iterables que se le pasó como parámetro.

(Si A tuviera 8 bytes y B tuviera 10 bytes, el resultado de zip va a ser un iterable de 8 tuplas, y los últimos 2 bytes de B van a ser descartados)

Cada vez que ciframos datos, es muy habitual que la clave de cifrado sea de menor longitud que los datos que estamos cifrando (salvo que estemos usando un One Time Pad, pero eso va a ser para un próximo artículo).

Para resolver este problema, podemos utilizar la función cycle(), del módulo itertools, que nos permite recorrer un iterable y, una vez que sea han terminado los elementos, empezar a recorrerlo nuevamente todas las veces que sea necesario.

Solamente tenemos que hacer unos pocos cambios en el código: (voy a poner unicamente el código modificado)

>>> from itertools import cycle
>>>
>>> C = bytes([ a^b for a,b in zip(A,cycle(B)) ])

NOTA: Si bien no es habitual, en el caso de que B tenga mayor longitud que A, no debemos preocuparnos, porque vamos a poder XORear sin problemas. Si intentamos utilizar cycle() tanto para A como para B, el programa va a empezar a consumir toda la memoria de nuestro sistema, porque nunca va a saber donde encontrar un fin.

Implementación Final

A continuación muestro el código final de la implementación de la función xor(), tal como está en el repositorio de habu:

def xor(a, b='0'.encode()):
    return bytes([ a^b for a,b in zip(a,cycle(b)) ])

El único cambio que se hizo es el de definir un valor por defecto a la función xor().

En el caso de que no sea pase un segundo valor (la clave), el XOReado se va a realizar con '0', lo cual no representa un nivel de seguridad, pero permite realizar el proceso, el cual muchas veces es utilizado para ofuscar datos.

Y a continuación podemos ver el código del comando habu.xor:

import click
from habu.lib.xor import xor

@click.command()
@click.option('-k', default='0', help='Encryption key')
@click.option('-i', type=click.File('rb'), required=True, help='Input file')
@click.option('-o', type=click.File('wb'), required=True, help='Output file')
def cmd_xor(k, i, o):
    """XOR cipher"""
    o.write(xor(i.read(), k.encode()))


if __name__ == '__main__':
    cmd_xor()

Como podemos ver, click hace un gran trabajo con el manejo de parámetros, y se encarga automáticamente de abrir y cerrar los archivos que se pasan por parámetro.

Conclusión

En este artículo vimos una implementación de XOR en Python y cómo implementar un comando utilizando el paquete 'Click' que permite utilizar archivos como parámetro.

En los próximos artículos

Vamos a ver cómo podemos mejorar la calidad de nuestro código, siguiendo las mejores prácticas e implementando pruebas unitarias, para verificar que nuestro código verdaderamente funciona como lo esperamos.

hacking python

Licencia Creative Commons
Creative Commons Atribución-CompartirIgual 4.0 Internacional.