Principios de Ofuscación
Última actualización
Última actualización
La ofuscación se utiliza ampliamente en muchos campos relacionados con el software para proteger la propiedad intelectual ( PI ) y otra información de propiedad exclusiva que pueda contener una aplicación.
Por ejemplo, el popular juego Minecraft utiliza el ofuscador para ofuscar y minimizar sus clases de Java. Minecraft también lanza mapas de ofuscación con información limitada como traductor entre las antiguas clases no ofuscadas y las nuevas clases ofuscadas para apoyar a la comunidad de modding.
Este es sólo un ejemplo de la amplia gama de formas en que se utiliza públicamente la ofuscación. Para documentar y organizar la variedad de métodos de ofuscación, podemos hacer referencia a . Este artículo de investigación organiza los métodos de ofuscación por capas , similar al modelo OSI pero para el flujo de datos de aplicaciones. A continuación se muestra la figura utilizada como descripción general completa de cada capa de taxonomía.
Luego, cada subcapa se divide en métodos específicos que pueden lograr el objetivo general de la subcapa.
En esta sala, nos centraremos principalmente en la capa de elementos de código de la taxonomía, como se ve en la siguiente figura.
Para utilizar la taxonomía, podemos determinar un objetivo y luego elegir un método que se ajuste a nuestros requisitos. Por ejemplo, supongamos que queremos ofuscar el diseño de nuestro código pero no podemos modificar el código existente. En ese caso, podemos inyectar código basura, resumido en la taxonomía:
Code Element Layer
> Obfuscating Layout
> Junk Codes
.
¿Pero cómo podría usarse esto de manera maliciosa? Los adversarios y los desarrolladores de malware pueden aprovechar la ofuscación para romper firmas o impedir el análisis de programas.
Para evadir firmas, los adversarios pueden aprovechar una amplia gama de reglas lógicas y sintácticas para implementar la ofuscación. Esto comúnmente se logra abusando de prácticas de ofuscación de datos que ocultan información identificable importante en aplicaciones legítimas.
Método de ofuscación
Objetivo
Array Transformation
Transforma una matriz dividiéndola, fusionándola, plegándola y aplanándola
Data Encoding
Codifica datos con funciones matemáticas o cifrados.
Data Procedurization
Sustituye datos estáticos con llamadas a procedimientos.
Data Splitting/Merging
Distribuye información de una variable en varias variables nuevas.
# Object Concatenation
La concatenación es un concepto de programación común que combina dos objetos separados en un solo objeto, como una cadena.
Un operador predefinido define dónde ocurrirá la concatenación para combinar dos objetos independientes. A continuación se muestra un ejemplo genérico de concatenación de cadenas en Python.
Dependiendo del lenguaje utilizado en un programa, puede haber operadores predefinidos diferentes o múltiples que se pueden usar para la concatenación. A continuación se muestra una pequeña tabla de lenguajes comunes y sus correspondientes operadores predefinidos.
Language
Concatenation Operator
Python
“+”
PowerShell
“+”, ”,”, ”$”, or no operator at all
C#
“+”, “String.Join”, “String.Concat”
C
“strcat”
C++
“+”, “append”
A continuación observaremos una regla estática de Yara e intentaremos utilizar la concatenación para evadir la firma estática.
Cuando un binario compilado se escanea con Yara, creará una alerta/detección positiva si la cadena definida está presente. Al utilizar la concatenación, la cadena puede ser funcionalmente la misma, pero aparecerá como dos cadenas independientes cuando se escanee, lo que no generará alertas.
Si el segundo bloque de código se escaneara con la regla Yara, ¡no habría alertas!
A partir de la concatenación, los atacantes también pueden utilizar caracteres no interpretados para alterar o confundir una firma estática. Estos se pueden utilizar de forma independiente o con concatenación, dependiendo de la solidez/implementación de la firma. A continuación se muestra una tabla de algunos caracteres comunes no interpretados que podemos aprovechar.
Character
Purpose
Example
Breaks
Break a single string into multiple sub strings and combine them
('co'+'ffe'+'e')
Reorders
Reorder a string’s components
('{1}{0}'-f'ffee','co')
Whitespace
Include white space that is not interpreted
.( 'Ne' +'w-Ob' + 'ject')
Ticks
Include ticks that are not interpreted
d`own`LoAd`Stri`ng
Random Case
Tokens are generally not case sensitive and can be any arbitrary case
dOwnLoAdsTRing
Utilizando el conocimiento que ha acumulado a lo largo de esta tarea, oculte el siguiente fragmento de PowerShell hasta que eluda las detecciones de Defender.
Para comenzar, le recomendamos dividir cada sección del código y observar cómo interactúa o se detecta. Luego puede dividir la firma presente en la sección independiente y agregarle otra sección hasta que tenga un fragmento limpio.
Una vez que crea que su fragmento está lo suficientemente ofuscado, envíelo al servidor web en http://MACHINE_IP
; Si tiene éxito, aparecerá una bandera en una ventana emergente.
Si todavía está atascado, le proporcionamos un tutorial de la solución a continuación.
Para comenzar a intentar limpiar este fragmento de código, debemos desglosarlo y comprender dónde se originan las alertas.
Podemos dividir el fragmento donde está presente cada cmdlet ( GetField
, SetValue
)
[Ref].Assembly.GetType('System.Management.Automation.AmsiUtils')
[Ref].Assembly.GetType('System.Management.Automation.AmsiUtils').GetField('amsiInitFailed','NonPublic,Static')
[Ref].Assembly.GetType('System.Management.Automation.AmsiUtils').GetField('amsiInitFailed','NonPublic,Static').SetValue($null,$true)
Ejecutemos el primer fragmento de código y veamos qué devuelve PowerShell .
No es sorprendente... podemos dividir aún más este fragmento dividiendo el ensamblado .NET y viendo qué parte genera una alerta.
Ahora sabemos que eso AmsiUtils
contribuye directamente a la alerta. A continuación, podemos apuntarlo con concatenación para dividirlo e intentar limpiar esta sección.
¡Éxito! Ahora podemos agregar nuestro siguiente fragmento a esta versión limpia del primer fragmento y ejecutarlo.
Ahora bien, este fragmento puede ser más difícil de rastrear…. Con algunos conocimientos previos de PowerShell, podemos asumir queNonPublic
son Static
bastante estándar y no contribuirían a la firma. Podemos asumir amsiInitFailed
que es sobre lo que Defender está alertando e intentar dividirlo.
¡Éxito! Ahora podemos agregar nuestro siguiente fragmento a esta versión limpia del primer y segundo fragmento y ejecutarlo.
Esto es interesante. ¿No parece haber ningún valor de parámetro que pueda contribuir a esta alerta? Esto debe significar que Defender alerta sobre la presencia de cada cmdlet juntos para determinar si se trata de un fragmento malicioso. Para resolver este problema, necesitamos separar los cmdlets del resto del fragmento. Esta técnica se conoce como separación de código relacionado y a menudo se ve junto con la concatenación; cubriremos este concepto con más detalle en la tarea 8.
Después de ofuscar las funciones básicas del código malicioso, es posible que pueda pasar las detecciones de software, pero aún es susceptible al análisis humano. Si bien no es un límite de seguridad sin políticas adicionales, los analistas y los ingenieros inversos pueden obtener un conocimiento profundo de la funcionalidad de nuestra aplicación maliciosa y detener las operaciones.
Los adversarios pueden aprovechar la lógica y las matemáticas avanzadas para crear códigos más complejos y difíciles de entender para combatir el análisis y la ingeniería inversa.
Método de ofuscación
Objetivo
Junk Code
Agregar instrucciones basura que no son funcionales, también conocidas como códigos auxiliares(code stubs)
Separation of Related Code
Separar códigos o instrucciones relacionados para aumentar la dificultad en la lectura del programa.
Stripping Redundant Symbols
Elimina información simbólica, como información de depuración u otras tablas de símbolos.
Meaningless Identifiers
Transformar un identificador significativo en algo sin sentido
Implicit Controls
Convierte instrucciones de controles explícitas en instrucciones implícitas
Dispatcher-based Controls
Determina el siguiente bloque que se ejecutará durante el tiempo de ejecución.
Probabilistic Control Flows
Introduce replicaciones de flujos de control con la misma semántica pero sintaxis diferente.
Bogus Control Flows
Flujos de control agregados deliberadamente a un programa pero nunca se ejecutarán
El flujo de control es un componente crítico de la ejecución de un programa que definirá cómo procederá lógicamente un programa. La lógica es uno de los factores determinantes más importantes para el flujo de control de una aplicación y abarca varios usos, como declaraciones if/else o bucles for . Tradicionalmente, un programa se ejecutará de arriba hacia abajo; cuando se encuentra una declaración lógica, continuará la ejecución siguiendo la declaración.
A continuación se muestra una tabla de algunas declaraciones lógicas que puede encontrar al tratar con flujos de control o lógica de programa.
Logic Statement
Purpose
if/else
Executes only if a condition is met, else it will execute a different code block
try/catch
Will try to execute a code block and catch it if it fails to handle errors.
switch case
A switch will follow similar conditional logic to an if statement but checks several different possible conditions with cases before resolving to a break or default
for/while loop
A for loop will execute for a set amount of a condition. A while loop will execute until a condition is no longer met.
Para concretar este concepto, podemos observar una función de ejemplo y su correspondiente CFG ( gráfico de flujo de control ) para representar sus posibles rutas de flujo de control.
¿Qué significa esto para los atacantes? Un analista puede intentar comprender la función de un programa a través de su flujo de control; mientras que el flujo problemático, lógico y de control es casi fácil de manipular y hacer arbitrariamente confuso. Cuando se trata de flujo de control, un atacante pretende introducir una lógica suficientemente oscura y arbitraria como para confundir a un analista, pero no demasiada como para generar más sospechas o potencialmente ser detectado por una plataforma como malicioso.
Para crear patrones de flujo de control arbitrarios, podemos aprovechar las matemáticas, la lógica y/u otros algoritmos complejos para inyectar un flujo de control diferente en una función maliciosa.
Podemos aprovechar los predicados para elaborar estos complejos algoritmos lógicos y/o matemáticos. Los predicados se refieren a la toma de decisiones de una función de entrada para devolver verdadero o falso . Desglosando este concepto en un nivel alto, podemos pensar en un predicado similar a la condición que usa una declaración if para determinar si un bloque de código se ejecutará o no, como se ve en el ejemplo de la tarea anterior.
El tema de los predicados opacos requiere una comprensión más profunda de las matemáticas y los principios informáticos, por lo que no lo cubriremos en profundidad, pero observaremos un ejemplo común.
En el fragmento de código anterior, la conjetura de Collatz solo realizará sus operaciones matemáticas si x > 1
, lo que dará como resultado 1
o TRUE
. Según la definición del problema de Collatz, siempre devolverá uno para una entrada de número entero positivo, por lo que la afirmación siempre devolverá verdadero si x
es un número entero positivo mayor que uno.
Para probar la eficacia de este predicado opaco, podemos observar su CFG ( Gráfico de flujo de control ) a la derecha. Si así es como se ve una función interpretada, imagínense cómo podría verse una función compilada para un analista.
Utilizando el conocimiento que ha acumulado a lo largo de esta tarea, póngase en la piel de un analista e intente decodificar la función original y el resultado del siguiente fragmento de código.
Si sigue correctamente las declaraciones impresas, obtendrá una marca que podrá enviar.
Los nombres de los objetos ofrecen algunos de los conocimientos más importantes sobre la funcionalidad de un programa y pueden revelar el propósito exacto de una función. Un analista todavía puede deconstruir el propósito de una función a partir de su comportamiento, pero esto es mucho más difícil si no hay contexto para la función.
La importancia de los nombres de objetos literales puede cambiar dependiendo de si el lenguaje se compila o se interpreta . Si se utiliza un lenguaje interpretado como Python o PowerShell , entonces todos los objetos importan y deben modificarse. Si se utiliza un lenguaje compilado como C o C# , generalmente sólo son significativos los objetos que aparecen en las cadenas. Un objeto puede aparecer en las cadenas mediante cualquier función que produzca una operación IO .
A continuación observaremos dos ejemplos básicos de cómo reemplazar identificadores significativos tanto para un lenguaje interpretado como para un lenguaje compilado.
Como ejemplo de lenguaje compilado, podemos observar un inyector de proceso escrito en C++ que informa su estado a la línea de comando.
Usemos cadenas para ver exactamente qué se filtró cuando se compiló este código fuente.
Observe que todo el iostream se escribió en cadenas, e incluso se filtró la matriz de bytes del código shell. Este es un programa más pequeño, ¡así que imagina cómo sería un programa completo y sin ofuscaciones!
Podemos eliminar comentarios y reemplazar los identificadores significativos para resolver este problema.
Ya no deberíamos tener ninguna información de cadena identificable y el programa estará a salvo del análisis de cadenas.
Puede notar que algunos cmdlets y funciones se mantienen en su estado original... ¿a qué se debe? Dependiendo de sus objetivos, es posible que desee crear una aplicación que aún pueda confundir a los ingenieros inversos después de la detección, pero que no parezca sospechosa de inmediato. Si un desarrollador de malware ofuscara todos los cmdlets y funciones, aumentaría la entropía tanto en el lenguaje interpretado como en el compilado, lo que daría como resultado puntuaciones de alerta EDR más altas. También podría hacer que un fragmento interpretado parezca sospechoso en los registros si parece aleatorio o está visiblemente muy ofuscado.
La estructura del código puede ser un problema molesto cuando se trata de todos los aspectos del código malicioso que a menudo se pasan por alto y no se identifican fácilmente. Si no se aborda adecuadamente en lenguajes interpretados y compilados, puede generar firmas o ingeniería inversa más sencilla por parte de un analista.
Como se explica en el documento de taxonomía antes mencionado, el código basura y el código de reordenamiento se utilizan ampliamente como medidas adicionales para agregar complejidad a un programa interpretado. Debido a que el programa no está compilado, un analista tiene una visión mucho mayor del programa y, si no se le infla artificialmente con complejidad, puede centrarse en las funciones maliciosas exactas de una aplicación.
La separación del código relacionado puede afectar tanto a los lenguajes interpretados como a los compilados y dar como resultado firmas ocultas que pueden ser difíciles de identificar. Un motor de firma heurística puede determinar si un programa es malicioso en función de las funciones circundantes o las llamadas API . Para eludir estas firmas, un atacante puede aleatorizar la aparición de código relacionado para engañar al motor haciéndole creer que es una llamada o función segura.
Los aspectos más menores de un binario compilado, como el método de compilación, pueden no parecer un componente crítico, pero pueden generar varias ventajas para ayudar a un analista. Por ejemplo, si un programa se compila como una versión de depuración, un analista puede obtener todas las variables globales disponibles y otra información del programa.
El compilador incluirá un archivo de símbolos cuando se compila un programa como compilación de depuración. Los símbolos suelen ayudar a depurar una imagen binaria y pueden contener variables globales y locales, nombres de funciones y puntos de entrada. Los atacantes deben ser conscientes de estos posibles problemas para garantizar prácticas de compilación adecuadas y que no se filtre información a un analista.
Afortunadamente para los atacantes, los archivos de símbolos se eliminan fácilmente mediante el compilador o después de la compilación. Para eliminar símbolos de un compilador como Visual Studio , necesitamos cambiar el destino de compilación de Debug
a Release
o usar un compilador más liviano como mingw.
Si necesitamos eliminar símbolos de una imagen precompilada, podemos usar la utilidad de línea de comandos: strip
.
A continuación se muestra un ejemplo del uso de strip para eliminar los símbolos de un binario compilado en gcc con la depuración habilitada.
Utilizando el conocimiento que ha acumulado a lo largo de esta tarea, elimine cualquier identificador significativo o información de depuración del código fuente de C++ a continuación utilizando AttackBox o su propia máquina virtual.
Una vez que esté adecuadamente ofuscado y eliminado, compile el código fuente usando MingW32-G++
y envíelo al servidor web en http://MACHINE_IP/
.
Nota: el nombre del archivo debe ser challenge-8.exe
para recibir la bandera.
Dos de los límites de seguridad más importantes para un adversario son los motores antivirus y las soluciones EDR ( Endpoint Detection & R esponse). Como se explicó en la , ambas plataformas aprovecharán una extensa base de datos de firmas conocidas denominadas firmas estáticas , así como firmas heurísticas que consideran el comportamiento de las aplicaciones.
El documento técnico antes mencionado: resume bien estas prácticas en la capa de elementos de código . A continuación se muestra una tabla de métodos cubiertos por la taxonomía en la subcapa de datos ofuscadores .
El documento técnico antes mencionado: resume bien estas prácticas en la subcapa de división/fusión de datos de la capa de elementos de código .
¿Qué significa esto para los atacantes? La concatenación puede abrir las puertas a varios vectores para modificar firmas o manipular otros aspectos de una aplicación. El ejemplo más común de concatenación utilizada en malware es romper firmas estáticas específicas , como se explica en la . Los atacantes también pueden usarlo de forma preventiva para dividir todos los objetos de un programa e intentar eliminar todas las firmas a la vez sin buscarlas, algo que se ve comúnmente en los ofuscadores, como se explica en la tarea 9.
Para obtener más información sobre ingeniería inversa, consulte el .
El documento técnico antes mencionado: resume bien estas prácticas en otras subcapas de la capa de elementos de código . A continuación se muestra una tabla de métodos cubiertos por la taxonomía en las subcapas de diseño de ofuscación y controles de ofuscación .
Aplicando este concepto a la ofuscación, se utilizan predicados opacos para controlar una salida y una entrada conocidas. El artículo, , afirma: “Un predicado opaco es un predicado cuyo valor es conocido por el ofuscador pero que es difícil de deducir. Se puede aplicar perfectamente con otros métodos de ofuscación, como el código basura, para convertir los intentos de ingeniería inversa en un trabajo arduo”. Los predicados opacos caen bajo los métodos falsos de flujo de control y flujo de control probabilístico del artículo de taxonomía; se pueden utilizar para agregar lógica arbitrariamente a un programa o refactorizar el flujo de control de una función preexistente.
La conjetura de Collatz es un problema matemático común que puede usarse como ejemplo de predicado opaco. Dice: Si se repiten dos operaciones aritméticas, devolverán una de cada número entero positivo. El hecho de que sepamos que siempre generará uno para una entrada conocida (un número entero positivo) significa que es un predicado opaco viable. Para obtener más información sobre la conjetura de Collatz, consulte el . A continuación se muestra un ejemplo de la conjetura de Collatz aplicada en Python.
El documento técnico antes mencionado: resume bien estas prácticas bajo el método de identificadores sin sentido de la capa de elemento de código .
Como ejemplo de un lenguaje interpretado, podemos observar el obsoleto del .
El documento técnico antes mencionado: resume bien estas prácticas bajo el método de eliminación de símbolos redundantes de la capa de elemento de código .
Se deben considerar varias otras propiedades antes de utilizar activamente una herramienta, como la entropía o el hash. Estos conceptos se tratan en la tarea 5 de la .