14 marzo 2010

Esteganografía sonora

En mi anterior artículo desarrollé una de las técnicas conocidas para ocultar mensajes en imágenes. A lo largo de este repasaremos otras técnicas utilizadas para esconder datos, pero esta vez en ficheros de sonido.

Como seguramente recuerde el lector, en mi artículo sobre esteganografía visual, se utilizaba una técnica que se basaba en introducir variaciones en puntos clave de la imagen de manera que, aunque estas variaciones fueran imperceptibles para el ojo humano, pudieran ser recopiladas por un programa informático que supiera en qué puntos buscar y en función de dichas variaciones fuese capaz de reconstruir el mensaje.

Utilizar también esta técnica con ficheros de sonidos es una opción evidente. Con un fichero de sonido que usase un formato sin pérdidas, como por ejemplo los ficheros wav, el algoritmo podría limitarse a codificar ceros y unos en puntos muy concretos procurando que su amplitud fuera mucho menor que la amplitud misma de la señal de audio, la cual se convertiría así en portadora del mensaje oculto. Existen multitud de formatos para codificar ceros y unos sobre señales de amplitud variable. Un ejemplo de estos formatos es la codificación Mánchester que, en su versión del IEEE 802.3, codifica unos como pendientes ascendentes (cambio de nivel bajo a nivel alto) y los ceros como pendientes descendentes (cambios de nivel alto a bajo).
De esta manera, un algoritmo de ocultamiento de datos podría escoger momentos periódicos del fichero de sonido, medir la amplitud en ese punto y forzar que la amplitud de las muestras inmediatamente precedentes y posteriores se ajustasen al nivel necesario para codificar en Manchester los unos y ceros de los que se compone el mensaje a ocultar. Si cada señal Manchester usa como valor medio el de la muestra original, y la deformación de las muestras inmediatamente anteriores y posteriores es de un amplitud reducida, la anomalía introducida es casi imperceptible para un oído humano no avisado. Por supuesto, y como es habitual en esteganografía, esta anomalía también será más perceptible conforme aumentamos la relación " (tamaño del mensaje a ocultar) / (tamaño del mensaje portador)".


(Si pincha en las imágenes siguientes podrá verlas con mayor resolución)





En las gráficas precedentes se pueden ver las ondas de amplitud (para el canal izquierdo y derecho) de un fragmento de un fichero de sonido en formato WAV. La primera pareja de ondas son la del fichero de sonido en su versión original, la segunda corresponde a la misma onda con un mensaje de texto oculto. He señalado en rojo algunas de las alteraciones introducidas en la señal para codificar cada bit, si se acercase el zoom un poco más se distinguirían los escalones propios de la codificación Mánchester.

La anomalía introducida mediante este método toma generalmente la forma de una pequeña distorsión en el sonido, particularmente en los solos agudos se nota como una tenue capa de ruido "blanco" que envuelve al sonido restándole definición. Comparando el fichero de sonido y el original se nota bastante bien, sobre todo en ficheros de sonido donde predominen los vocalistas... aunque si en el fichero predominan instrumentación diversa y "ruidosa" como guitarras eléctricas, o si sencillamente el oyente no está alertado de antemano, puede pasar mucho más desapercibido.

Este método tiene una serie de inconvenientes muy importantes. Para empezar utiliza un formato de audio que actualmente está en desuso salvo para aplicaciones muy concretas. Los ficheros WAV son de un tamaño enorme por lo que la mayor parte de los usuarios utilizan ficheros comprimidos, como por ejemplo MP3, que les permiten tamaños mucho más manejables y una calidad aceptable. Así, los ficheros sin pérdidas suelen quedar relegados a masters y piezas de trabajo de alta calidad para la edición de sonido... por lo que un fichero de este tipo con las anomalías que hemos descrito hasta ahora sería ciertamente llamativo. Por otro lado, este tipo de codificación exige que la reconstrucción del mensaje ocultado se haga en base a medidas muy precisas en momentos muy concretos del fichero de audio, una actualización de las librerías de audio utilizadas para la escritura/lectura puede alterar infinitesimalmente la definición de los puntos sucesivos de muestreo de manera que escuchemos un poco "demasiado pronto" o "demasiado tarde" cada una de las muestras donde están los bits escondidos lo que alteraría irremisiblemente la correcta reconstrucción de la información escondida.

Para experimentar con esta técnica, programé en python una aplicación denominada stegaudio . Para ejecutar stegaudio en linux hace falta la instalación de un par de librerías adicionales de tratamiento de audio. Para hacerlo en Ubuntu no hay más que teclear la siguiente orden:

$ sudo aptitude install python-tk python-tksnack


Una vez hecho esto, ya estamos en condiciones de ejecutar stegaudio.

Para pedirle a stegaudio que esconda un fichero en un archivo .wav tendríamos que introducir la siguiente orden:

$ ./stegaudio.py -i fichero_wav_original.wav -d fichero_a_ocultar -o fichero_wav_portador.wav


Para extraer un fichero escondido, la orden es igualmente sencilla:


$ ./stegaudio.py -x fichero_wav_portador.wav -d fichero_rescatado

A la hora de esconder información, el núcleo de stegaudio es la función hideFile() de la librería libstegaudio.py:

 def hideFile(self, data_file_name):
        """ This function encodes data file into audio host file."""
        self.data_file_size = (os.stat(data_file_name))[stat.ST_SIZE]
        self.calculateSeparation(self.getDataFileSize())
        self.data_file = open(data_file_name, 'rb')
        if (self.getSeparation() < 0): #This means that data file is too big.
            return ERROR
        else:
            self.encodeHeader()
            self.byte=self.data_file.read(1)
            self.bytes_written = 0
            while (self.byte):
                self.byte_in_bits = self.int2bin(n=ord(self.byte), count=8)
                self.encodeBits(self.byte_in_bits)
                self.byte = self.data_file.read(1)
                self.bytes_written = self.bytes_written + 1
                self.percentage_done = int((float(self.bytes_written) /   /float(self.data_file_size)) *  100)
                print str(self.percentage_done) + "% : " + /str(self.byte_in_bits)
            self.hostFileSound.write(self.hostFileName)
            return OK

La orden calculateSeparation() compara el tamaño del fichero portador y el que hay que esconder y determina cada cuantas muestras de sonido del portador hay que introducir una anomalía que codifique un bit del fichero a esconder. Obviamente, conforme el intervalo de separación entre anomalías se vaya reduciendo, para archivos a esconder m
ás grandes, estas serán más perceptibles para el usuario que escuche el fichero portador. En cuanto a la orden encodeHeader(), se encarga de cifrar en el tamaño del fichero ocultado al comienzo mismo del fichero portador utilizando para ello 32 anomalías (cada una de llas representaría 1 bit) sin intervalos de separación entre una y otra y distribuidas entre todos los canales de los que disponga el fichero de sonido (p.ej. si fuera un fichero estéreo, 2 canales, cada uno tendría 16 anomalías). Por último, la orden encodeBits() va introduciendo byte a byte el fichero a ocultar en el portador:


 def encodeBits(self, bit_string,  position=0):
        """ This function deals with writing of long bit strings."""
        if (position == 0):
            self.current_position = self.last_position
        else:
            self.current_position = position
        self.x = 0
        self.channel_number = self.getNumberOfChannels()
        for bit in bit_string:
            self.setBit(self.current_position, int(bit),  self.x)
            if (self.x < self.channel_number-1):
                self.x = self.x + 1
            else:
                self.x = 0
                self.current_position = self.current_position + /
self.step * 2 + self.separation
        self.last_position = self.current_position

Como se puede ver, encodeBits() distribuye los bits por los
diferentes canales de los que consta el fichero portador, de manera que si el fichero tuviera 2 canales uno tendría los bits impares y el otro los impares. Esta distribución entre todos los canales permite ocultar mayor cantidad de información y, al equidistribuir las manipulaciones, permite hacer las anomalías menos llamativas (imaginemos si no como llamaría la atención tener un canal, p.ej el izquierdo, con un sonido limpio y el derecho con algo de "nieve" o ruido). La clave de esta función es a su vez setBit() que se encarga de codificar el bit a ocultar en formato Mánchester sobre la señal portadora:

 def setBit(self, startPosition, value, track = LEFT):
        """ To put a bit in a position. """
        self.original_values = []
        self.modified_values = []
        if (startPosition < (self.hostFileSound.length(unit='SAMPLES') - /
len(self.step_range))):
            for i in range(startPosition, startPosition + /
len(self.step_range)):
                self.channel_values = self.hostFileSound.sample(i)
                self.original_values.append(self.channel_values.split())
        else: # To avoid an index out of bounds in the last few samples.
            for i in range(startPosition,self.hostFileSound.length/
(unit='SAMPLES')):
                self.channel_values = self.hostFileSound.sample(i)
                self.original_values.append(self.channel_values.split())
        self.overall = 0
        for original_value in self.original_values:
            self.overall = self.overall + int(original_value[track]) 
        self.mean_value = self.overall/len(self.step_range)
        # It looks like tkinter has kind of value range limit /
depending of filetype, so I have to put values within this limits.
        if ((self.hostFileSound['fileformat'] == 'WAV') or /
(self.hostFileSound['fileformat'] == 'RAW')):
            if (self.mean_value >= (WAV_MAX - self.delta)):
                self.mean_value = WAV_MAX - self.delta
            elif (self.mean_value <= (WAV_MIN + self.delta)):
                self.mean_value = WAV_MIN + self.delta
        self.modified_values = copy.deepcopy(self.original_values)
        for modified_value in self.modified_values:
            if (value == 0):
                if (self.modified_values.index(modified_value)< /
self.step):
                    modified_value[track] = self.mean_value + /
self.delta
                else:
                    modified_value[track] = self.mean_value - /
self.delta
            else:
                if (self.modified_values.index(modified_value)< /
self.step):
                    modified_value[track] = self.mean_value - /self.delta
                else:
                    modified_value[track] = self.mean_value + /
self.delta
        self.r = 0
        if (startPosition < (self.hostFileSound.length(unit='SAMPLES') - /len(self.step_range))):
            for i in range(startPosition, /
startPosition+len(self.step_range)):
                if (track == RIGHT):
                    self.hostFileSound.sample(i, /
right = self.modified_values[self.r][RIGHT])
                else:
                    self.hostFileSound.sample(i, /
left = self.modified_values[self.r][LEFT])
                self.r =self.r + 1
        else: # To avoid an index out of bounds in the last few samples.
            for i in range(startPosition, /
self.hostFileSound.length(unit='SAMPLES')):
                if (track == RIGHT):
                    self.hostFileSound.sample(i, right = /self.modified_values[self.r][RIGHT])
                else:
                    self.hostFileSound.sample(i, left = /
self.modified_values[self.r][LEFT])
                self.r =self.r + 1

Hay que señalar dos variables muy importantes en la función anter
ior: por un lado self.step_range que define el ancho de un pulso Mánchester (p.ej, un 1 codificado en Mánchester con un step_range 4 se compondría de 2 muestras de nivel bajo y 2 de nivel alto); la otra variable importante es self.delta en la que se define la diferencia de niveles entre muestras bajas y altas en la codificación Mánchester. La anomalía percibible será mayor conforme lo sean los valores tanto de step_range y delta ya que suponen una mayor deformación de la señal original. A nivel teórico, el step_range puede ser perfectamente 2 y el delta 1, pero en la realidad eso puede dar problemas ya que exigiría una precisión milimétrica en la toma de muestras y en su valoración en el extremo receptor a la hora de reconstruir el mensaje oculto. Si el fichero portador se va a transportar en su formato .wav es probable que no haya problema en la extracción del mensaje oculto. Sin embargo, si la idea es grabar ese wav en un CD para hacerlo pasar por un CD de música clásico luego será necesaria la recodificación en formato wav, por lo que pueden producirse pérdidas de muestras aún procurando que las frecuencias de muestreo de todas esas conversiones son las mismas. En este último caso, resulta altamente recomendable aumentar tanto el step_range como el delta al máximo tolerable para asegurar que los pulsos Manchester siguen siendo percibibles después de todas esas conversiones.
No voy a perder tiempo explicando el proceso de extracción de un fichero escondido ya que este proceso es exactamente el recíproco al que hemos cisto hasta ahora.

Si utilizásemos un fichero comprimido con un algoritmos con pérdi
das (mucho más común), como por ejemplo un MP3, la técnica anterior no valdría ya que estos los algoritmos de compresión realizan un muestreo de la señal original y descartan porciones de esta por lo que si en esos fragmentos hay información escondida nos resultará imposible reconstruir el mensaje original. En esos casos, resulta obligado estudiar el algoritmo de compresión utilizado en el fichero concreto que queremos utilizar de portadora para encontrar una manera de esconder datos.
En el caso de fichero MP3 tenemos dos opciones: la primera es trabajar en el dominio de la frecuencia y la segunda aprovechar particularidades del formato de los archivos MP3.
El primer caso es el más académico y complejo. El formato MP3 elige las frecuencias más altas a la hora de descartar. Se basa en que el oído humano común sólo puede percibir las frecuencias más bajas del espectro de frecuencias por lo que eliminar la información referente a las bandas altas permitiría reducir el volumen de datos de los ficheros de sonido sin empeorar sensiblemente su calidad. Aún así, hay gente con un oído particularmente sensible que sí que nota la ausencia de las frecuencias altas y que prefiere utilizar algoritmos sin pérdidas como el WAV (por lo general es la misma gente que se quejó de la pérdida de calidad del sonido de los CD respecto de los vinilos :-P ). Un algoritmo esteganográfico que supiese distinguir unas frecuencias de otras y se centrase en trabajar sólo con las frecuencias bajas podría esconder sus datos sin temor de que el algoritmo de compresión se llevase por delante parte del mensaje oculto. Este enfoque es el seguido por herramientas como mp3stego.

El segundo caso es quizás más sencillo e inmediato. Y es el enfoque usado por algoritmos como el de Achmad. El formato MP3 es muy sencillo. Un fichero MP3 es una mera sucesión de frames independientes entra si. Cada uno de estos frames tiene su propia cabecera, sus propios campos independientes del resto de los frames y recoge la información referida a un instante determinado del sonido grabado. Los lectores de MP3 se limitan
a leer y volcar el contenido de los frames uno detrás de otro hasta que ya no quedan más en el archivo. La importancia de la independencia de estos frames entre si radica en que se pueden eliminar e introducir otros nuevos a voluntad sin que el fichero quede inutilizable.

(Pinche en la imagen siguiente para ver el diagrama con mayor resolución)


Como se puede ver en el diagrama anterior, cada frame va señalado en su comienzo por una secuencia xFFF, el resto de los campos le dicen al lector de MP3 cómo debe hacer el sonido contenido en el campo de datos.


El algoritmo de Achmad saca partido de los denominados frames homogéneos que codifican los silencios infinitesimales que se dan entre unos sonidos y otros. Un frame homogéneo puede tener cualquier contenido en su campo de datos y el lector que lo lea siempre lanzará un silencio. De esta manera, el algoritmo Achmad localiza los frames homogéneos de un fichero y graba en ello el mensaje a ocultar. Para encontrar estos frames sólo hay que buscar aquellos que tienen los bytes a partir del 36 de cada frame fijados a un mismo valor. El cam po de datos comienza en el byte número 36 de este tipo de frames y es ahí donde se puede esconder nuestro mensaje.

(Pinche en la imagen siguiente para ver el diagrama con mayor resolución)



En la imagen anterior se puede ver el contenido hexadecimal de un fichero MP3 y resaltado un frame homogeneo. En dicha imagen salta a la vista que a partir del byte 36 todos los demás tienen el valor OxFF lo que significa que son homogéneos.
El algoritmo Achmad, en su concepción original, cuenta con la ventaja de que no altera el tamaño del fichero y no introduce anomalía alguna en el fichero de sonido por lo que es difícilmente detectable. Por otro lado presenta el inconveniente de que el tamaño del mensaje que se puede ocultar está limitado por el número y tamaño de los frames homogéneos del fichero de sonido elegido como portadora. Para ilustrar este algoritmo, programé una pequeña aplicación denominada stegmp3 que implementa el algoritmo de Achmad y que incorpora como novedad sobre este la posibilidad de introducir frames homogéneos adicionales con el fin de ocultar ficheros de cualquier tamaño. El precio de esta innovación con respecto al algoritmo original es que si el tamaño del fichero a ocultar es demasiado grande y por ello hay que generar muchos frames homogéneos adicionales la anomalía comienza a ser perceptible en forma de silencios intercalados. Vamos a estudiar con más detalle cómo funciona stegmp3.

Para empezar, su uso es exactamente igual que el de stegaudio. Así, para ocultar un fichero no habría más que teclear:


$ ./stegmp3.py -i fichero_mp3_original.mp3 -d fichero_a_ocultar -o fichero_mp3_portador.mp3



Y para extraerlo:


$ ./stegmp3.py -x fichero_mp3_portador.mp3 -d fichero_rescatado


Cuando se llama a stegmp3 este comienza creando un objeto MP3HostFile, el cual analiza en su constructor el fichero mp3 que se ha pasado como parámetro con el fin de sacar una lista con todos los frames presentes en el fichero (mediante la llamada a getFrames() ). Luego esa lista se utiliza 0 bien para localizar frames homogéneos (mediante la llamada a searchForHomogenousFrames() ), o bien para buscar frames con datos ocultos (mediante searchForHostFrames() ) si el programa se usa para extracción.


La extracción de los diferentes frames con
getFrames() es relativamente fácil gracias a la marca OxFF que hay al principio de cada uno.

En cuanto a searchForHomogeneousFrames() examina cada uno de los frames que getFrames() ha depositado en una lista al efecto:




def searchForHomogeneousFrames(self):
        """This function examines frames stored in our list and /
finds out which of them are homogeneous."""
        self.homogeneous_frame = FALSE
        self.first_homogeneous_frame_found = FALSE
        self.counter = 0
        if (self.originalMP3FileName==None):
            print "Searching for frames marked with signature..."
        else:
            print "Searching for homogeneous frames..."
        for frame in self.frame_list:
            for index in range(DATA_OFFSET, frame.size-1):
                if not (struct.unpack('=B',  frame.data[index])[0]) == /
(struct.unpack('=B', frame.data[index+1])[0]):
                    self.homogeneous_frame = FALSE
                    break
                else:
                    self.homogeneous_frame = TRUE
            if self.homogeneous_frame:
                (self.frame_list[self.frame_list.index(frame)])./
homogeneous = TRUE
                self.homogeneous_frame = FALSE
                self.counter += 1
                #We are not going to use first homogenous /
frame to store data. We are going to store hidden
                #data size instead. So we don't count this /
frame as usable.
                if self.first_homogeneous_frame_found: 
                    self.maximum_size_for_hiding += /
(self.frame_list[self.frame_list.index(frame)]).size - /
DATA_OFFSET - self.signature_length
                else:
                    self.first_homogeneous_frame_found = TRUE
                sys.stdout.write("|")
                sys.stdout.flush()
            else:
                sys.stdout.write("X")
                sys.stdout.flush()
        self.maximum_size_for_hiding -= /
STEGMP3_HEADER_SIZE
        print ("\nFound " + str(self.counter) + /
" frames useful for hiding.")
        if (not self.originalMP3FileName==None):
            print ("We can hide up to " + /str(self.maximum_size_for_hiding)  + " bytes.")


Como se puede ver en el código anterior, la identificación se produce comparando entre si los valores a partir del 36 de cada uno de los frames y viendo si están fijados a un mismo valor. Se puede ver también que el primer frame homogéneo no se cuenta a la hora de calcular la capacidad máxima total de los frames homogéneos del fichero, esto es así porque el primer frame de este tipos se utilizará para codificar el tamaño del fichero oculto.

Una vez localizados todos los frames homogéneos del fichero es cuando se llama a hideFile().


def hideFile(self,  data_file_name_to_hide):
        """This function hides data_file_name_to_hide in MP3 file""" 
        self.file_to_hide_name = data_file_name_to_hide
        self.file_to_hide = open(self.file_to_hide_name)
        self.file_to_hide_size = /
(os.stat(data_file_name_to_hide))[stat.ST_SIZE]
        print "Data size to hide: " + str(self.file_to_hide_size)+ /
" bytes..."
        if self.file_to_hide_size > self.maximum_size_for_hiding:
            self.createStubFrames(self.file_to_hide_size,  /self.maximum_size_for_hiding)
        self.file_to_hide_payload = self.file_to_hide.read()
        self.encodeHeader(self.file_to_hide_size)
        print "Hiding " + self.file_to_hide_name + " inside of " + /self.hostMP3FileName + "..."
        self.writeStegFile(self.file_to_hide_payload)
        return OK


Si hideFile() detecta que el fichero que se pretende ocultar excede la capacidad de los frames homogéneos presentes en el fichero de sonido portador, llama a createStubFrames() para crear frames homogéneos adicionales hasta conseguir la capacidad de
seada.

Una vez que se ha asegurado que hay suficientes frames homogéneos como para ocultar el fichero deseado, se procede a llamar a encodeHeader() con el fin de guardar en el primer frame homogéneo el tamaño del fichero ocultado:


def encodeHeader(self, size):
        """This function encodes file to hide size in the very first /
STEGMP3_HEADER_SIZE bytes of the first frame capable for hiding"""
        for index,  frame in enumerate(self.frame_list):
            if frame.homogeneous: #We are searching just for first /
homogeneous frame only.
                self.size_packed = struct.pack('=L', size)
                frame.data = frame.data[:DATA_OFFSET] + self.signature + /
frame.data[DATA_OFFSET + self.signature_length:]
                frame.data = frame.data[:DATA_OFFSET + self.signature_length] /
+ self.size_packed + /
frame.data[DATA_OFFSET + self.signature_length + STEGMP3_HEADER_SIZE:]
                self.frame_list[index] = frame
                break
Hay que resaltar el uso, tanto en esta función como en writeStegFile(), de la variable signature como firma. Su uso se debe a que, una vez rellenados los frames homogéneos con los datos a ocultar su contenido se vuelve variable por lo que ya no vale el mé
todo que hemos usado hasta el momento para distinguirlos. Por eso es necesario marcar con una firma cada frame homogéneo utilizado. A la hora de extraer el mensaje oculto, la función searchForHostFrames() localizará los frames con datos ocultos gracias a que su contenido comienza con dicha firma.

Una vez hecho todo esto es cuando ya se puede ir recorriendo la lista de frames homogéneos volcando en ellos el contenido del fichero a ocultar mediante la llamada a writeStegFile().

El resultado se puede ver en el siguiente volcado hexadecimal del contenido de un fichero MP3 con datos ocultos:

(Pinche en la imagen siguiente para ver el diagrama con mayor resolución)



Fíjese por último que la cadena que utiliza por defecto stegmp3 para marcar los frames homogéneos utilizados es >>D6NT3<<.

Igual que en el caso de stegaudio, no voy a comentar nada del proceso de extracción ya que es lo mismo que el de ocultación pero a la recíproca.

No hay comentarios: