Ofuscación en capas: una taxonomía de técnicas de ofuscación de software para seguridad en capas

En esta sección se examinan las técnicas de ofuscación para elementos de código específicos. Esta capa cubre la mayoría de las publicaciones en el área de ofuscación de software. Como se muestra en la Fig. 4, de acuerdo con los elementos a los que apunta una técnica de ofuscación, dividimos esta categoría en cinco subcategorías: diseños de ofuscación, controles de ofuscación, funciones de ofuscación de datos y clases de ofuscación.

Fig. 4
figura 4

Las técnicas de ofuscación de código-capa de elemento

Ofuscación de diseño

Diseño de ofuscación revuelve el diseño de códigos o instrucciones, manteniendo la sintaxis original intacto. Esta sección analiza cuatro estrategias de ofuscación de diseño: clasificadores sin sentido, eliminación de símbolos redundantes, separación de códigos relacionados y códigos basura.

Identificadores sin sentido

Este enfoque también se conoce como ofuscación léxica que transforma identificadores significativos en identificadores sin sentido. Para la mayoría de los lenguajes de programación, la adopción de reglas de nomenclatura significativas y uniformes (por ejemplo, Notación húngara (Simonyi 1999)) es una buena práctica de programación. Aunque estos nombres se especifican en los códigos fuente, algunos permanecerían en el software liberado por defecto. Por ejemplo, los nombres de las variables y funciones globales en C/C++ se guardan en binarios, y todos los nombres de Java se reservan en códigos de bytes. Debido a que estos nombres significativos pueden facilitar el análisis de programas contradictorios, deberíamos mezclarlos. Para hacer más confusos los identificadores ofuscados, Chan y Yang (2004) propusieron emplear deliberadamente los mismos nombres para objetos de diferentes tipos o dentro de diferentes dominios. Tales enfoques han sido adoptados por ProGuard (2016) como un esquema de ofuscación predeterminado para programas Java.

Eliminar símbolos redundantes

Esta estrategia elimina la información simbólica redundante del software liberado, como la información de depuración de la mayoría de los propgrams (Low 1998). Además, hay otros símbolos redundantes para formatos particulares de programas. Por ejemplo, los archivos ELF contienen tablas de símbolos que registran los pares de identificadores y direcciones. Al adoptar opciones de compilación predeterminadas para compilar programas C/C++, como el uso de LLVM (Lattner y Adve 2004), los binarios generados contienen dichas tablas de símbolos. Para eliminar dicha información redundante, los desarrolladores pueden emplear la herramienta de eliminación de Linux. Otro ejemplo con información redundante son los códigos smali de Android. De forma predeterminada, los códigos smali generados contienen información que comienza con .línea y .fuente, que se puede eliminar para fines de ofuscación (Dalla Preda y Maggi 2017).

Separar códigos relacionados

Un programa es más fácil de leer si sus códigos relacionados lógicamente también están físicamente cerca (Collberg et al. 1997). Por lo tanto, separar códigos o instrucciones relacionados puede aumentar las dificultades de lectura. Es aplicable tanto a códigos fuente (por ejemplo, reordenamiento de variables (Low 1998)) como a códigos ensambladores (por ejemplo, instrucciones de reordenamiento (Wroblewski 2002)). En la práctica, emplear saltos incondicionales para reescribir un programa es un enfoque popular para lograr esto. Por ejemplo, los desarrolladores pueden barajar los códigos de ensamblaje y luego emplear goto para reconstruir el flujo de control original (You y Yim 2010). Este enfoque es popular para códigos ensambladores y códigos de bytes Java con disponibilidad de instrucciones goto (Dalla Preda y Maggi 2017).

Códigos basura

Esta estrategia agrega instrucciones basura que no son funcionales. Para los binarios, podemos agregar instrucciones de no operación (NOP o 0x00) (Dalla Preda y Maggi 2017; Marcelli et al. 2018). Además, también podemos agregar métodos basura, como agregar métodos extintos en códigos smali de Android (Dalla Preda y Maggi 2017). Los códigos basura normalmente pueden cambiar las firmas de los códigos y, por lo tanto, escapar del reconocimiento de patrones estáticos.

Debido a que la ofuscación de diseño no interfiere con la sintaxis del código original, es menos propensa a problemas de compatibilidad o errores. Por lo tanto, tales técnicas son las más favoritas en la práctica. Además, las técnicas de identificadores sin sentido y eliminación de símbolos redundantes pueden reducir el tamaño de los programas, lo que los hace aún más atractivos (ProGuard 2016). Sin embargo, la potencia de la ofuscación del diseño es limitada. Tiene una resistencia prometedora a los ataques de desenfoque porque algunas transformaciones son unidireccionales, que no se pueden revertir. Sin embargo, es difícil cambiar parte de la información de diseño, como los identificadores de métodos del SDK de Java. Esta información residual es esencial para que los adversarios recuperen la información confusa. Por ejemplo, Bichsel et al. (2016) intentaron desenfocar aplicaciones ofuscadas de ProGuard, y recuperaron con éxito alrededor del 80% de los nombres.

Controles de ofuscación

Este tipo de técnicas de ofuscación transforma los controles de códigos para aumentar la complejidad del programa. Se puede lograr a través de flujos de control falsos, flujos de control probabilísticos, controles basados en despachadores y controles implícitos.

Flujos de control falsos

Los flujos de control falsos se refieren a los flujos de control que se agregan deliberadamente a un programa pero que nunca se ejecutarán. Puede aumentar la complejidad de un programa, p. ej., en McCabe complexity (McCabe 1976) o Harrison metrics (Harrison y Magel 1981). Por ejemplo, la complejidad de McCabe (McCabe 1976) se calcula como el número de aristas en un gráfico de flujo de control menos el número de nodos, y luego más dos veces los componentes conectados. Para aumentar la complejidad de McCabe, podemos introducir nuevos bordes o agregar nuevos bordes y nodos a un componente conectado.

Para garantizar la inalcanzabilidad de flujos de control falsos, Collberg et al. (1997) sugirieron emplear predicados opacos. Definieron predictor opaco como el predicado cuyo resultado se conoce durante el tiempo de ofuscación, pero es difícil de deducir mediante el análisis estático de programas. En general, un predicado opaco puede ser constantemente verdadero (PT), constantemente falso (PF) o dependiente del contexto (P?). Hay tres métodos para crear predicados opacos: esquemas numéricos, esquemas de programación y esquemas contextuales.

Esquemas numéricos

Los esquemas numéricos componen predicados opacos con expresiones matemáticas. Por ejemplo, 7×2-1≠y2 es constantemente verdadero para todos los enteros x e y. Podemos emplear directamente dichos predicados opacos para introducir flujos de control falsos. La Figura 5a muestra un ejemplo, en el que el predicado opaco garantiza que el flujo de control falso (es decir, la rama else) no se ejecutará. Sin embargo, los atacantes tendrían mayores posibilidades de detectarlos si empleamos los mismos predicados opacos con frecuencia en un programa ofuscado. Arboit (2002), por lo tanto, propuso generar una familia de predicados opacos automáticamente, de modo que un ofuscador pueda elegir un predicado opaco único cada vez.

Fig. 5
figura 5

Ofuscación de flujo de control con predicados opacos

Otro enfoque matemático con mayor seguridad es emplear funciones criptográficas, como la función hash \(\mathcal {H}\) (Sharif et al. 2008), y cifrado homomórfico (Zhu y Thomborson 2005). Por ejemplo, podemos sustituir un predicado x = = c por \(\mathcal {H} (x)==c_{hash}\) para ocultar la solución de x para esta ecuación. Tenga en cuenta que este enfoque es generalmente empleado por el malware para evadir el análisis dinámico de programas. También podemos emplear funciones criptográficas para cifrar ecuaciones que no se pueden cumplir. Sin embargo, tales predicados opacos incurren en muchos gastos generales.

Para componer constantes opacas resistentes al análisis estático, Moser et al. (2007) sugirieron emplear problemas de 3-SAT, que son NP-hard. Esto es posible porque uno puede tener algoritmos eficientes para componer problemas tan difíciles (Selman et al. 1996). Por ejemplo, Tiella y Ceccato (2017) demostraron cómo componer predicados opacos con problemas de camarilla k.

Para componer constantes opacas resistentes al análisis dinámico, Wang et al. (2011) propusieron componer predicados opacos con una forma de conjeturas sin resolver que se repiten muchas veces. Debido a que los bucles son un desafío para el análisis dinámico, el enfoque en la naturaleza debe ser resistente al análisis dinámico. Ejemplos de tales conjeturas incluyen la conjetura de Collatz, la conjetura de 5x+1, la conjetura de Matthews. La Figura 5b muestra cómo emplear conjeturas de Collatz para introducir flujos de control falsos. No importa cómo inicialicemos x, el programa termina con x = 1, y originalCodes() siempre se puede ejecutar.

Esquemas de programación

Debido a que el análisis de programas contradictorios es una amenaza importante para los predicados opacos, podemos emplear problemas desafiantes de análisis de programas para componer predicados opacos. Collberg et al. se sugirieron dos problemas clásicos, análisis de punteros y programas concurrentes.

En general, el análisis de punteros se refiere a determinar si dos punteros pueden o pueden apuntar a la misma dirección. Algunos problemas de análisis de punteros pueden ser NP-hard para análisis estático o incluso indecidibles (Landi y Ryder 1991). Otra ventaja es que las operaciones de puntero son muy eficientes durante la ejecución. Por lo tanto, los desarrolladores pueden componer predicciones opacas resistentes y eficientes con problemas de análisis de punteros bien diseñados, como mantener punteros a algunos objetos con estructuras de datos dinámicas (Collberg et al. 1998a).

Programas simultáneos o programas paralelos es otro problema desafiante. En general, una región paralela de n declaraciones tiene n! diferentes formas de ejecución. La ejecución no solo está determinada por el programa, sino también por el estado de tiempo de ejecución de un equipo host. Collberg et al. (1998a) propuso emplear programas simultáneos para mejorar el enfoque basado en punteros actualizando simultáneamente los punteros. Majumdar y Thomborson (2006) propusieron emplear programas paralelos distribuidos para componer predicados opacos.

Además, algunos enfoques componen predicados opacos con trucos de programación, como aprovechar mecanismos de manejo de excepciones. Por ejemplo, Dolz y Parra (2008) propusieron utilizar el mecanismo try-catch para componer predicados opacos para.Net y Java. Los eventos de excepción incluyen división por cero, puntero nulo, índice fuera de rango o incluso excepciones de hardware particulares(Chen et al. 2009). La semántica original del programa se puede lograr a través de esquemas de manejo de excepciones personalizados. Sin embargo, estos predicados opacos no tienen base de seguridad y son vulnerables a ataques avanzados hechos a mano.

Esquemas contextuales

Los esquemas contextuales se pueden emplear para componer predicados opacos variantes (es decir, {P?}). Los predicados deben tener algunas propiedades deterministas de tal manera que puedan ser empleados para ofuscar programas. Por ejemplo, Drape y et al. (2009) propusieron componer tales predicados opacos que son invariantes bajo una restricción contextual, por ejemplo, el predicado opaco x mod3==1 es constantemente verdadero si x mod3:1?x++: x = x + 3. Palsberg et al. (2000) propusieron predicados dinámicos opacos, que incluyen una secuencia de predicados correlacionados. El resultado de la evaluación de cada predicado puede variar en cada ejecución. Sin embargo, mientras los predicados estén correlacionados, el comportamiento del programa es determinista. La Figura 5c muestra un ejemplo de predicados opacos dinámicos. No importa cómo nos inicializar *p y *q, el programa es equivalente a y=x+3,x=y+3.

La resistencia de los flujos de control falsos depende principalmente de la seguridad de los predicados opacos. Una propiedad de seguridad ideal para predicados opacos es que requieren tiempo exponencial en el peor de los casos para romperse, pero solo tiempo polinómico para construir. Tenga en cuenta que algunos predicados opacos están diseñados con tales problemas de seguridad, pero pueden implementarse con fallas. Por ejemplo, los problemas 3-SAT propuestos por Ogiso et al. (2003) se basan en configuraciones de problemas triviales que se pueden simplificar fácilmente. Si estos predicados opacos se implementan adecuadamente, prometen ser resistentes.

Flujos de control probabilísticos

Los flujos de control falsos pueden causar problemas en el análisis estático de programas. Sin embargo, son vulnerables al análisis dinámico de programas porque los flujos de control falsos están inactivos. La idea de flujos de control probabilísticos adopta una estrategia diferente para hacer frente a la amenaza (Pawlowski et al. 2016). Introduce réplicas de flujos de control con la misma semántica pero sintaxis diferente. Al recibir la misma entrada varias veces, el programa puede comportarse de manera diferente para diferentes tiempos de ejecución. La técnica también es útil para combatir los ataques de canal lateral (Crane et al. 2015).

Nótese que la estrategia de los flujos de control probabilísticos es similar a los flujos de control falsos con predicados opacos contextuales. Pero son de naturaleza diferente, ya que los predicados opacos contextuales introducen caminos muertos, aunque no introducen códigos basura.

Controles basados en despachador

Un control basado en despachador determina los siguientes bloques de códigos que se ejecutarán durante el tiempo de ejecución. Estos controles son esenciales para la ofuscación de flujo de control porque pueden ocultar los flujos de control originales contra el análisis estático del programa.

Un enfoque de ofuscación basado en el despachador principal es el aplanamiento de flujo de control, que transforma los códigos de profundidad en códigos poco profundos con más complejidad. Wang et al. (2000) propusieron en primer lugar el enfoque. La Figura 6 muestra un ejemplo de su papel que transforma un bucle while en otra forma con una caja de cambio. Para realizar tal transformación, el primer paso es transformar el código en una representación equivalente con instrucciones if-then-goto como se muestra en la Fig. 6; luego modifican las instrucciones goto con instrucciones de mayúsculas y minúsculas como se muestra en la Fig. 6. De esta manera, la semántica original del programa se realiza implícitamente controlando el flujo de datos de la variable del conmutador. Debido a que el orden de ejecución de los bloques de código está determinado por la variable dinámicamente, uno no puede conocer los flujos de control sin ejecutar el programa. Cappaert y Preneel (2010) formalizaron el aplanamiento de flujo de control como el empleo de un nodo de despachador (por ejemplo, conmutador) que controla el siguiente bloque de código que se ejecutará; después de ejecutar un bloque, el control se transfiere de nuevo al nodo de despachador. Además, hay varias mejoras en el aplanamiento de flujo de código. Por ejemplo, para mejorar la resistencia al análisis estático de programas en la variable de conmutación, Wang et al. (2001) propuso introducir problemas de análisis de indicadores. Para complicar aún más el programa, Chow et al. (2001) propuso añadir bloques de código falsos.

Fig. 6
figura 6

Enfoque de aplanamiento de control-flujo propuesto por Wang et al. (2000)

László y Kiss (2009) propusieron un mecanismo de aplanamiento de flujo de control para manejar la sintaxis específica de C++, como try-catch, while-do, continue. El mecanismo se basa en un árbol de sintaxis abstracta y emplea un patrón de diseño fijo. Para cada bloque de código que se ofusca, construye una instrucción while en el bucle exterior y un compuesto de caja de conmutación dentro del bucle. El compuesto de caja de interruptores implementa la semántica del programa original, y la variable de interruptor también se emplea para terminar el bucle exterior. Cappaert y Preneel (2010) encontraron que los mecanismos podrían ser vulnerables al análisis local, es decir, la variable switch se asigna inmediatamente de modo que los adversarios puedan inferir el siguiente bloque a ejecutar solo mirando un bloque actual. Propusieron un enfoque reforzado con varios trucos, como emplear asignación de referencia (por ejemplo, swVar=swVar+1) en lugar de asignación directa (por ejemplo, swVar=3), reemplazar la asignación a través de if-else con una expresión de asignación uniforme, y emplear funciones unidireccionales para calcular el sucesor de un bloque básico.

Además del aplanamiento de flujo de control, hay varias otras investigaciones de ofuscación basadas en el despachador (por ejemplo, (Linn y Debray 2003;Ge et al. 2005; Zhang et al. 2010; Schrittwieser y Katzenbeisser 2011)). Linn y Debray (2003) propusieron ofuscar binarios con funciones de rama que guían la ejecución en base a la información de la pila. De manera similar, Zhang et al. (2010) propusieron emplear funciones de rama para ofuscar programas orientados a objetos, que definen un estilo de invocación de método unificado con un grupo de objetos. Para mejorar la seguridad de dichos mecanismos, Ge et al. (2005) propusieron ocultar la información de control en otro proceso independiente y emplear comunicaciones entre procesos. Schrittwieser y Katzenbeisser (2011) propusieron emplear bloques de código diversificados que implementen la misma semántica.

La ofuscación basada en el despachador es resistente al análisis estático porque oculta el gráfico de flujo de control de un programa de software. Sin embargo, es vulnerable al análisis dinámico de programas o a enfoques híbridos. Por ejemplo, Udupa et al. (2005) propusieron un enfoque híbrido para revelar los flujos de control ocultos con análisis estático y dinámico.

Controles implícitos

Esta estrategia convierte las instrucciones de control explícitas en instrucciones implícitas. Puede impedir que los ingenieros de reversa aborden los flujos de control correctos. Por ejemplo, podemos reemplazar las instrucciones de control de los códigos de ensamblaje (por ejemplo, jmp y jne) con una combinación de mov y otras instrucciones que implementan la misma semántica de control (Balachandran y Emmanuel 2011).

Tenga en cuenta que todos los enfoques de ofuscación de flujo de control existentes se centran en la transformación a nivel sintáctico, mientras que la protección a nivel semántico rara vez se ha discutido. Aunque pueden demostrar cierta resistencia a los ataques, su eficacia de ofuscación con respecto a la protección semántica sigue sin estar clara.

Ofuscación de datos

Las técnicas de ofuscación de datos actuales se centran en tipos de datos comunes, como enteros, cadenas y matrices. Podemos transformar datos a través de la división, fusión, proceduración, codificación, etc.

División/fusión de datos

La división de datos distribuye la información de una variable en varias variables nuevas. Por ejemplo, una variable booleana se puede dividir en dos variables booleanas, y realizar operaciones lógicas en ellas puede obtener el valor original.

La fusión de datos, por otro lado, agrega varias variables en una sola variable. Collberg et al. (1998b) demostró un ejemplo que fusiona dos enteros de 32 bits en un entero de 64 bits. Ertaul y Venkatesh (2005) propusieron otro método que agrupa varias variables en un espacio con logaritmos discretos.

Proceduración de datos

La proceduración de datos sustituye los datos estáticos por llamadas a procedimientos. Collberg et al. (1998b) propuso sustituir las cadenas por una función que puede producir todas las cadenas especificando valores de parámetros específicos. Drape y et al. (2004) propusieron codificar datos numéricos con dos funciones inversas f y g. Para asignar un valor v a una variable i, lo asignamos a una variable inyectada j como j=f(v). Para usar i, invocamos g (j) en su lugar.

Codificación de datos

Codificación de datos codifica datos con funciones matemáticas o cifrados. Ertaul y Venkatesh (2005) propusieron codificar cadenas con cifrados afines (por ejemplo, cifrado Caser) y emplear logaritmos discretos para empaquetar palabras. Fukushima et al. (2008) propusieron codificar los números claros con operaciones or exclusivas y luego descifrar el resultado del cálculo antes de la salida. Kovacheva (2013) propuso cifrar cadenas con el cifrado RC4 y luego descifrarlas durante el tiempo de ejecución.

Transformación de matriz

La matriz es una de las estructuras de datos más utilizadas. Para ofuscar matrices, Collberg et al. (1998b) discutieron varias transformaciones, como dividir un array en varios subarrays, fusionar varios arrays en un array, plegar un array para aumentar su dimensión, o aplanar un array para reducir la dimensión. Ertaul y Venkatesh (2005) sugirieron transformar los índices de matrices con funciones compuestas. Zhu et al. (2006); Zhu (2007) propuso emplear cifrado homomórfico para la transformación de matrices, incluido el cambio de índice, el plegado y la halagación. Por ejemplo, podemos mezclar los elementos de una matriz con i∗m mod n, donde i es el índice original, n es el tamaño de la matriz original, y m y n son primos relativos.

Métodos de ofuscación

Método inline / outline

Un método es un procedimiento independiente que puede ser llamado por otras instrucciones del programa. Method inline reemplaza la llamada de procedimiento original con el cuerpo de la función en sí. El esquema del método funciona de manera opuesta, extrayendo una secuencia de instrucciones y abstrayendo un método. Son buenas compañías que pueden ofuscar la abstracción original de procedimientos (Collberg et al. 1997).

Clon de método

Si se invoca un método en gran medida, podemos crear replicaciones del método y llamar aleatoriamente a uno de ellos. Para confundir la interpretación contradictoria, cada versión de la replicación debe ser única de alguna manera, por ejemplo, adoptando diferentes transformaciones de ofuscación (Collberg et al. 1997) o diferentes firmas (Ertaul y Venkatesh 2004).

Agregación/dispersión de métodos

La idea es similar a la ofuscación de datos. Podemos agregar métodos irrelevantes en un método o dispersar un método en varios métodos(Collberg et al. 1997; Mínimo en 1998).

Método proxy

Este enfoque crea métodos proxy para confundir la ingeniería inversa. Por ejemplo, podemos crear los proxies como métodos estáticos públicos con identificadores aleatorios. Puede haber varios proxies distintos para el mismo método (Dalla Preda y Maggi 2017). El enfoque es extremadamente útil cuando las firmas del método no se pueden cambiar (Protsenko y Muller 2013).

Clases de ofuscación

Las clases de ofuscación comparten algunas ideas similares con los métodos de ofuscación, como la división y la clonación (Collberg et al. 1998b). Sin embargo, dado que class solo existe en lenguajes de programación orientados a objetos, como JAVA y.NET, los discutimos como una categoría única. A continuación presentamos las principales estrategias para ofuscar las clases.

Modificadores de eliminación

Los programas orientados a objetos contienen modificadores (p. ej., público, privado) para restringir el acceso a las clases y a los miembros de las clases. Eliminar modificadores elimina tales restricciones y hace que todos los miembros sean públicos (Protsenko y Muller 2013). Este enfoque puede facilitar la implementación de otros métodos de ofuscación de clase.

Clase de división/coalescencia

La idea de coalescencia/división es ofuscar la intención de los desarrolladores al diseñar las clases (Sosonkin et al. 2003). Al unir clases, podemos transferir variables locales o grupos de instrucción locales a otra clase(Fukushima et al. 2003).

Aplanamiento de jerarquía de clases

La interfaz es una poderosa herramienta para programas orientados a objetos. Similar al proxy de método, podemos crear proxies para clases con interfaces (Sosonkin et al. 2003). Sin embargo, una forma más potente es romper la relación de herencia original entre clases con interfaces. Al permitir que cada nodo de un subárbol en la jerarquía de clases implemente la misma interfaz, podemos aplanar la jerarquía(Foket et al. 2012).

Deja una respuesta

Tu dirección de correo electrónico no será publicada.