Ejemplo de esteganografía en imágenes con Python

Introducción

Buscando y buscando librerías para ocultar mensajes en imágenes utilizando Python encontré algunas, pero ninguna funcionó en mi máquina. Ya fuera al momento de instalarla o al momento de utilizarla.

Así que decidí hacerlo a mano, y aprender un poco del proceso. Al final, pude ocultar texto para más tarde recuperarlo. A esta técnica se le llama esteganografía.

No utilicé ninguna librería, sólo PIL para obtener los pixeles de una imagen. Los métodos explicados aquí puede que sean redundantes, pero son bastante explicativos.

Antes de continuar, recomiendo y casi obligo a leer cómo es que modificamos el LSB de cada byte de cada nivel de color en un pixel.

Recuerda tener instalado Python y pip.

Nota: si quieres ver el código completo míralo en GitHub.

Ocultar mensaje en imágenes con Python

Vamos a empezar ocultando información. Veremos paso por paso.

Obtener pixeles de una imagen

Primeramente necesitamos los pixeles como un arreglo de dos dimensiones. El código es sencillo, con la altura y anchura de la imagen hacemos un ciclo dentro de otro. Luego, accedemos a los pixeles en la posición x, y. Y los vamos imprimiendo.

from PIL import Image

imagen = Image.open("back_to_the_future.png") # Aquí puedes cambiar el nombre de la imagen
pixeles = imagen.load()

tamaño = imagen.size
anchura = tamaño[0]
altura = tamaño[1]

print("La anchura de la imagen es {}".format(anchura))
print("La altura de la imagen es {}".format(altura))
for x in range(anchura):
    for y in range(altura):
        pixel = pixeles[x, y]
        print(pixel)

Nota: para evitar que salgan miles de líneas en la terminal, puedes presionar Ctrl + C unos segundos después de ejecutar el script, ya que se imprimirán muchas de ellas.

Como se observa, cada pixel se compone de R, G, B.

Obtener nivel de color por cada pixel

Sólo tenemos que modificar un poco el código anterior. Quedaría así:

from PIL import Image

imagen = Image.open("back_to_the_future.png") # Aquí puedes cambiar el nombre de la imagen
pixeles = imagen.load()

tamaño = imagen.size
anchura = tamaño[0]
altura = tamaño[1]

print("La anchura de la imagen es {}".format(anchura))
print("La altura de la imagen es {}".format(altura))
for x in range(anchura):
    for y in range(altura):
        pixel = pixeles[x, y]

        rojo = pixel[0]
        verde = pixel[1]
        azul = pixel[2]
        print("Rojo: {}".format(rojo))
        print("Verde: {}".format(verde))
        print("Azul: {}".format(azul))

Y ahora ya imprime el valor de cada color, por cada pixel.

Ya vimos cómo obtener el nivel de cada color, ahora podemos modificarlo. Pero antes, veamos cómo obtener los bits que escribiremos.

Obtener lista de bits de una palabra

Sé que este método no será el mejor ni el más óptimo, pero repito que todo es con fin de explicar las cosas adecuadamente.

Necesitamos escribir una función que, dada una cadena, devuelva una lista de bits. Por ejemplo, si escribo “Hola” debe devolver [‘0’, ‘1’, ‘0’, ‘0’, ‘1’, ‘0’, ‘0’, ‘0’, ‘0’, ‘1’, ‘1’, ‘0’, ‘1’, ‘1’, ‘1’, ‘1’, ‘0’, ‘1’, ‘1’, ‘0’, ‘1’, ‘1’, ‘0’, ‘0’, ‘0’, ‘1’, ‘1’, ‘0’, ‘0’, ‘0’, ‘0’, ‘1’].

Se ve complicado al inicio, pero no lo es. Primero, convierte cada letra al número que le corresponde en el código ascii. Luego, cada código es convertido a binario (un byte) y más tarde, por cada bit en ese byte creamos una lista:

def obtener_representacion_ascii(caracter):
	return ord(caracter)

def obtener_representacion_binaria(numero):
	return bin(numero)[2:].zfill(8)

def obtener_lista_de_bits(texto):
	lista = []
	for letra in texto:
		representacion_ascii = obtener_representacion_ascii(letra)
		representacion_binaria = obtener_representacion_binaria(representacion_ascii)
		for bit in representacion_binaria:
			lista.append(bit)
	return lista

print("Hola en bits es")
print(obtener_lista_de_bits("Hola"))

Al ejecutarlo, se ve esto:

Pero ahora que lo pienso, necesitamos poner unos bits de terminación. Es decir, si tenemos una imagen muy grande y sólo queremos ocultar “Hola”, ¿qué pasa con los demás pixeles? no vamos a recorrer cada pixel sin escribirle nada, y tampoco lo vamos a llenar de ceros.

Mejor ponemos un carácter de terminación; ya que cuando leamos el mensaje oculto tendremos que leer únicamente hasta dicho símbolo. Entonces necesitamos modificar nuestra lista de bits para que regrese los bits de la palabra, y también unos bits de terminación:

caracter_terminacion = [1, 1, 1, 1, 1, 1, 1, 1]

def obtener_representacion_ascii(caracter):
	return ord(caracter)

def obtener_representacion_binaria(numero):
	return bin(numero)[2:].zfill(8)

def obtener_lista_de_bits(texto):
	lista = []
	for letra in texto:
		representacion_ascii = obtener_representacion_ascii(letra)
		representacion_binaria = obtener_representacion_binaria(representacion_ascii)
		for bit in representacion_binaria:
			lista.append(bit)
	for bit in caracter_terminacion:
		lista.append(bit)
	return lista

print("Hola en bits es")
print(obtener_lista_de_bits("Hola"))

Al ejecutarlo, se ve lo mismo que antes. Pero con la diferencia de que ahora, al final, está nuestro carácter de terminación.

Ahora que ya aprendimos esto, vamos a ir modificando un color por cada bit que haya en nuestra lista.

Modificar colores

Antes de juntar todo esto, vamos a ver cómo modificar un color. Recordemos que un pixel tiene 3 colores: Rojo, Verde y Azul. Y cada nivel tiene un valor entre 0 y 255.

Supongamos que nuestro color rojo es 200, y nuestro bit que vamos a ocultar es 1. El código quedaría así:

def cambiar_ultimo_bit(byte, nuevo_bit):
	return byte[:-1] + str(nuevo_bit)

def binario_a_decimal(binario):
	return int(binario, 2)

def obtener_representacion_binaria(numero):
	return bin(numero)[2:].zfill(8)

def modificar_color(color_original, bit):
	color_binario = obtener_representacion_binaria(color_original)
	color_modificado = cambiar_ultimo_bit(color_binario, bit)
	return binario_a_decimal(color_modificado)

color_original = 200
color_modificado = modificar_color(color_original, 1) # Cambiar el LSB de 200 por un 1
print("Original: {}".format(color_original))
print("Modificado: {}".format(color_modificado))

Al ejecutarlo…

 Juntando todo

Ya que vimos cómo hacer cada cosa, vamos a juntar todo y hacerlo funcional. Por cierto, para modificar un pixel hacemos esto:

pixeles[x, y] = (rojo_modificado, verde_modificado, azul_modificado)

Es decir, le pasamos una tupla. Y para guardar todo el arreglo de pixeles (es decir, la imagen con el texto oculto) hacemos esto:

imagen.save(ruta_imagen_salida)

Finalmente, quiero explicar que hay un contador para saber si ya terminamos de escribir el mensaje… si ya terminamos, entonces terminamos el ciclo. También sirve para saber si pudimos escribir o no el mensaje completo.

He aquí el código

"""
	Ocultar mensaje en una imagen utilizando esteganografía, ocultando un bit en cada nivel de color

	Nota: recuerda instalar Pillow:
		pip install Pillow

	@author parzibyte
	@date 06-04-2018
	@web parzibyte.me/blog
"""
from PIL import Image
import math #Utilizado sólo para redondear hacia abajo

caracter_terminacion = [1, 1, 1, 1, 1, 1, 1, 1]


def obtener_representacion_ascii(caracter):
	return ord(caracter)

def obtener_representacion_binaria(numero):
	return bin(numero)[2:].zfill(8)

def cambiar_ultimo_bit(byte, nuevo_bit):
	return byte[:-1] + str(nuevo_bit)

def binario_a_decimal(binario):
	return int(binario, 2)

def modificar_color(color_original, bit):
	color_binario = obtener_representacion_binaria(color_original)
	color_modificado = cambiar_ultimo_bit(color_binario, bit)
	return binario_a_decimal(color_modificado)

def obtener_lista_de_bits(texto):
	lista = []
	for letra in texto:
		representacion_ascii = obtener_representacion_ascii(letra)
		representacion_binaria = obtener_representacion_binaria(representacion_ascii)
		for bit in representacion_binaria:
			lista.append(bit)
	for bit in caracter_terminacion:
		lista.append(bit)
	return lista

def ocultar_texto(mensaje, ruta_imagen_original, ruta_imagen_salida="salida.png"):
	print("Ocultando mensaje...".format(mensaje))
	imagen = Image.open(ruta_imagen_original)
	pixeles = imagen.load()

	tamaño = imagen.size
	anchura = tamaño[0]
	altura = tamaño[1]

	lista = obtener_lista_de_bits(mensaje)
	contador = 0
	longitud = len(lista)
	for x in range(anchura):
		for y in range(altura):
			if contador < longitud:
				pixel = pixeles[x, y]


				rojo = pixel[0]
				verde = pixel[1]
				azul = pixel[2]

				if contador < longitud:
					rojo_modificado = modificar_color(rojo, lista[contador])
					contador += 1
				else:
					rojo_modificado = rojo

				if contador < longitud:
					verde_modificado = modificar_color(verde, lista[contador])
					contador += 1
				else:
					verde_modificado = verde

				if contador < longitud:
					azul_modificado = modificar_color(azul, lista[contador])
					contador += 1
				else:
					azul_modificado = azul

				pixeles[x, y] = (rojo_modificado, verde_modificado, azul_modificado)
			else:
				break
		else:
			continue
		break

	if contador >= longitud:
		print("Mensaje escrito correctamente")
	else:
		print("Advertencia: no se pudo escribir todo el mensaje, sobraron {} caracteres".format( math.floor((longitud - contador) / 8) ))

	imagen.save(ruta_imagen_salida)


ocultar_texto("Hola, mundo. Esto es un mensaje oculto desde parzibyte.me/blog", "oveja.png")

Cuando lo ejecuto, sale esto:

En mi caso, la imagen es la de una oveja. Originalmente es esta:

La que tiene el mensaje oculto es esta: https://github.com/parzibyte/esteganografia-python/blob/master/salida.png

 

No he probado con imágenes JPG, pero no lo recomiendo, ya que (creo) comprimen los pixeles o algo así, perdiendo nuestros bits ocultos. Por favor prueba  sólo con imágenes PNG.

Ya hemos terminado de ocultar. Hora de leer.

Leer mensaje oculto en imágenes

Esto es un poco más sencillo. Ahora no modificamos ningún color, sólo leemos el LSB de cada nivel. Concatenamos todo en un byte; cuando el byte es un byte (es decir, cuando la cadena mide 8) obtenemos su carácter y ese lo concatenamos en una variable llamada mensaje.

Por ejemplo, si mi byte es “01000000”, entonces su representación decimal es 64, y eso, según el código ascii es el arroba @.

Ese arroba lo concatenamos en otra variable, y así vamos formando nuestro mensaje final.

Ahh, otra cosa. Si encontramos el carácter de terminación, nos damos por bien servidos y dejamos de leer los pixeles.

He aquí el código:

"""
	Leer mensaje en una imagen utilizando esteganografía, leyendo un bit en cada nivel de color

	Nota: recuerda instalar Pillow:
		pip install Pillow

	@author parzibyte
	@date 06-04-2018
	@web parzibyte.me/blog
"""
from PIL import Image

caracter_terminacion = "11111111"
def obtener_lsb(byte):
	return byte[-1]

def obtener_representacion_binaria(numero):
	return bin(numero)[2:].zfill(8)

def binario_a_decimal(binario):
	return int(binario, 2)

def caracter_desde_codigo_ascii(numero):
	return chr(numero)

def leer(ruta_imagen):
	imagen = Image.open(ruta_imagen)
	pixeles = imagen.load()

	tamaño = imagen.size
	anchura = tamaño[0]
	altura = tamaño[1]

	byte = ""
	mensaje = ""

	for x in range(anchura):
		for y in range(altura):
			pixel = pixeles[x, y]

			rojo = pixel[0]
			verde = pixel[1]
			azul = pixel[2]


			byte += obtener_lsb(obtener_representacion_binaria(rojo))
			if len(byte) >= 8:
				if byte == caracter_terminacion:
					break
				mensaje += caracter_desde_codigo_ascii(binario_a_decimal(byte))
				byte = ""

			byte += obtener_lsb(obtener_representacion_binaria(verde))
			if len(byte) >= 8:
				if byte == caracter_terminacion:
					break
				mensaje += caracter_desde_codigo_ascii(binario_a_decimal(byte))
				byte = ""

			byte += obtener_lsb(obtener_representacion_binaria(azul))
			if len(byte) >= 8:
				if byte == caracter_terminacion:
					break
				mensaje += caracter_desde_codigo_ascii(binario_a_decimal(byte))
				byte = ""

		else:
			continue
		break
	return mensaje

mensaje = leer("salida.png")
print("El mensaje oculto es:")
print(mensaje)

En mi caso, la imagen se llama salida.png. Si quieres, descarga la imagen de la oveja modificada, pon el nombre que indico, ejecuta el script y verás que sale el mensaje:

Con eso terminamos. Ya veremos más tarde cómo podemos regar el mensaje, cifrarlo, etcétera.

Estoy aquí para ayudarte 🤝💻


Estoy aquí para ayudarte en todo lo que necesites. Si requieres alguna modificación en lo presentado en este post, deseas asistencia con tu tarea, proyecto o precisas desarrollar un software a medida, no dudes en contactarme. Estoy comprometido a brindarte el apoyo necesario para que logres tus objetivos. Mi correo es parzibyte(arroba)gmail.com, estoy como@parzibyte en Telegram o en mi página de contacto

No te pierdas ninguno de mis posts 🚀🔔

Suscríbete a mi canal de Telegram para recibir una notificación cuando escriba un nuevo tutorial de programación.

4 comentarios en “Ejemplo de esteganografía en imágenes con Python”

  1. Hola, necesitaría saber si se puede obtener
    alguna relación entre píxeles y fila/columnas.-
    Para ejemplificar, tengo un widget TEXT y necesito relacionar
    el contenido en fila/columnas para establecer las dimensiones
    de este y la ventana.-

  2. muy buen aporte amigo, pero como puedo ver ambas imagenes al mismo tiempo
    como para saber cual es la original y la codificada, aunque no sería mucha la diferencia entre ambas.

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *