En informática , el código automodificable ( SMC o SMoC ) es un código que altera sus propias instrucciones mientras se ejecuta , generalmente para reducir la longitud de la ruta de instrucciones y mejorar el rendimiento o simplemente para reducir el código repetitivamente similar , simplificando así el mantenimiento . El término generalmente solo se aplica al código donde la automodificación es intencional, no en situaciones en las que el código se modifica accidentalmente debido a un error como un desbordamiento de búfer .
El código automodificable puede implicar sobrescribir instrucciones existentes o generar código nuevo en tiempo de ejecución y transferir el control a ese código.
La automodificación se puede utilizar como una alternativa al método de "establecimiento de indicadores" y la ramificación condicional del programa, que se utiliza principalmente para reducir la cantidad de veces que es necesario probar una condición.
El método se utiliza con frecuencia para invocar código de prueba/depuración condicional sin requerir sobrecarga computacional adicional para cada ciclo de entrada/salida .
Las modificaciones se podrán realizar:
En cualquier caso, las modificaciones se pueden realizar directamente en las propias instrucciones del código de máquina , superponiendo nuevas instrucciones sobre las existentes (por ejemplo: alterar una comparación y bifurcarla a una bifurcación incondicional o, alternativamente, un ' NOP ').
En la arquitectura IBM System/360 y sus sucesores hasta z/Architecture , una instrucción EXECUTE (EX) superpone lógicamente el segundo byte de su instrucción de destino con los 8 bits de orden inferior del registro 1. Esto proporciona el efecto de automodificación aunque la instrucción real en el almacenamiento no se altera.
La automodificación se puede lograr de diversas maneras dependiendo del lenguaje de programación y su soporte para punteros y/o acceso a 'motores' de compiladores o intérpretes dinámicos:
El código automodificable es bastante sencillo de implementar cuando se utiliza lenguaje ensamblador . Las instrucciones se pueden crear dinámicamente en la memoria (o bien superponerlas sobre el código existente en el almacenamiento de programas no protegidos), [1] en una secuencia equivalente a las que un compilador estándar puede generar como código objeto . Con los procesadores modernos, puede haber efectos secundarios no deseados en la memoria caché de la CPU que deben tenerse en cuenta. El método se utilizó con frecuencia para probar condiciones de "primera vez", como en este ejemplo de ensamblador IBM/360 comentado adecuadamente . Utiliza la superposición de instrucciones para reducir la longitud de la ruta de instrucciones en (N×1)−1 donde N es el número de registros en el archivo (−1 es la sobrecarga para realizar la superposición).
SUBRTN NOP ¿ABIERTO POR PRIMERA VEZ AQUÍ?* El NOP es x'4700'<Dirección_de_apertura> OI SUBRTN+1,X'F0' SI, CAMBIAR NOP A RAMA INCONDICIONAL (47F0...) ABRIR ENTRADA Y ABRIR EL ARCHIVO DE ENTRADA YA QUE ES LA PRIMERA VEZABIERTO OBTENER ENTRADA EL PROCESAMIENTO NORMAL SE REANUDA AQUÍ ...
Un código alternativo podría implicar probar una "bandera" cada vez. La bifurcación incondicional es ligeramente más rápida que una instrucción de comparación, además de reducir la longitud total de la ruta. En sistemas operativos posteriores para programas que residen en almacenamiento protegido, esta técnica no se podía utilizar y, por lo tanto, se utilizaba en su lugar el cambio del puntero a la subrutina . El puntero residiría en almacenamiento dinámico y se podría alterar a voluntad después del primer paso para omitir la APERTURA (tener que cargar un puntero primero en lugar de una bifurcación directa y un enlace a la subrutina agregaría N instrucciones a la longitud de la ruta, pero habría una reducción correspondiente de N para la bifurcación incondicional que ya no sería necesaria).
A continuación se muestra un ejemplo en lenguaje ensamblador Zilog Z80 . El código incrementa el registro "B" en el rango [0,5]. La instrucción de comparación "CP" se modifica en cada bucle.
;========== ORG 0H LLAMAR FUNC00 DETENER ;========== FUNC00: LD A , 6 LD HL , etiqueta01 + 1 LD B , ( HL ) etiqueta00: INC B LD ( HL ), B etiqueta01: CP $ 0 JP NZ , etiqueta00 RET ;==========
El código automodificable se utiliza a veces para superar las limitaciones del conjunto de instrucciones de una máquina. Por ejemplo, en el conjunto de instrucciones Intel 8080 , no se puede introducir un byte desde un puerto de entrada que esté especificado en un registro. El puerto de entrada está codificado estáticamente en la propia instrucción, como el segundo byte de una instrucción de dos bytes. Mediante el uso de código automodificable, es posible almacenar el contenido de un registro en el segundo byte de la instrucción y, a continuación, ejecutar la instrucción modificada para lograr el efecto deseado.
Algunos lenguajes compilados permiten explícitamente la automodificación del código. Por ejemplo, el verbo ALTER en COBOL puede implementarse como una instrucción de bifurcación que se modifica durante la ejecución. [2] Algunas técnicas de programación por lotes implican el uso de código automodificable. Clipper y SPITBOL también proporcionan funciones para la automodificación explícita. El compilador Algol en los sistemas B6700 ofrecía una interfaz al sistema operativo mediante la cual el código en ejecución podía pasar una cadena de texto o un archivo de disco con nombre al compilador Algol y luego podía invocar la nueva versión de un procedimiento.
En los lenguajes interpretados, el "código de máquina" es el texto fuente y puede ser susceptible de edición sobre la marcha: en SNOBOL, las sentencias fuente que se ejecutan son elementos de una matriz de texto. Otros lenguajes, como Perl y Python , permiten que los programas creen código nuevo en tiempo de ejecución y lo ejecuten utilizando una función eval , pero no permiten que se modifique el código existente. La ilusión de modificación (aunque en realidad no se esté sobrescribiendo ningún código de máquina) se logra modificando los punteros de función, como en este ejemplo de JavaScript:
var f = función ( x ) { devolver x + 1 }; // asigna una nueva definición a f: f = new Function ( 'x' , 'return x + 2' );
Las macros Lisp también permiten la generación de código en tiempo de ejecución sin analizar una cadena que contenga el código del programa.
El lenguaje de programación Push es un sistema de programación genética diseñado explícitamente para crear programas que se modifican a sí mismos. Si bien no es un lenguaje de alto nivel, no es de tan bajo nivel como el lenguaje ensamblador. [3]
Antes de la aparición de múltiples ventanas, los sistemas de línea de comandos podían ofrecer un sistema de menú que implicaba la modificación de un script de comandos en ejecución. Supongamos que un archivo de script (o "por lotes") DOS MENU.BAT contiene lo siguiente: [4] [nb 1]
:comenzar SHOWMENU.EXE
Al iniciar MENU.BAT desde la línea de comandos, SHOWMENU presenta un menú en pantalla, con posible información de ayuda, ejemplos de uso, etc. Finalmente, el usuario realiza una selección que requiere que se ejecute un comando SOMENAME : SHOWMENU sale después de reescribir el archivo MENU.BAT para que contenga
:comenzar SHOWMENU.EXE Llamar a alguien .BAT IR A inicio
Debido a que el intérprete de comandos DOS no compila un archivo de script y luego lo ejecuta, ni lee el archivo completo en la memoria antes de comenzar la ejecución, ni tampoco se basa en el contenido de un búfer de registro, cuando SHOWMENU sale, el intérprete de comandos encuentra un nuevo comando para ejecutar (es invocar el archivo de script SOMENAME , en una ubicación de directorio y a través de un protocolo conocido por SHOWMENU), y después de que ese comando se completa, vuelve al inicio del archivo de script y reactiva SHOWMENU listo para la próxima selección. Si la opción del menú es salir, el archivo se reescribiría de nuevo a su estado original. Aunque este estado inicial no tiene uso para la etiqueta, se requiere esta, o una cantidad equivalente de texto, porque el intérprete de comandos DOS recuerda la posición de byte del próximo comando cuando debe iniciar el próximo comando, por lo tanto, el archivo reescrito debe mantener la alineación para que el punto de inicio del próximo comando sea de hecho el inicio del próximo comando.
Además de la conveniencia de un sistema de menú (y posibles funciones auxiliares), este esquema significa que el sistema SHOWMENU.EXE no está en la memoria cuando se activa el comando seleccionado, una ventaja significativa cuando la memoria es limitada. [4] [5]
Los intérpretes de la tabla de control pueden considerarse, en cierto sentido, "automodificados" por los valores de datos extraídos de las entradas de la tabla (en lugar de estar codificados manualmente de forma específica en declaraciones condicionales del formato "SI entradax = 'yyy'").
Algunos métodos de acceso de IBM tradicionalmente utilizaban programas de canal automodificables , donde un valor, como una dirección de disco, se lee en un área referenciada por un programa de canal, donde es utilizado por un comando de canal posterior para acceder al disco.
El IBM SSEC , demostrado en enero de 1948, tenía la capacidad de modificar sus instrucciones o tratarlas exactamente como datos. Sin embargo, la capacidad rara vez se utilizó en la práctica. [6] En los primeros días de las computadoras, el código automodificable se usaba a menudo para reducir el uso de memoria limitada, o mejorar el rendimiento, o ambas cosas. También se usaba a veces para implementar llamadas y retornos de subrutinas cuando el conjunto de instrucciones solo proporcionaba instrucciones simples de ramificación o salto para variar el flujo de control . [7] [8] Este uso todavía es relevante en ciertas arquitecturas ultra- RISC , al menos teóricamente; vea por ejemplo la computadora de un conjunto de instrucciones . La arquitectura MIX de Donald Knuth también usó código automodificable para implementar llamadas de subrutinas. [9]
El código automodificable se puede utilizar para diversos fines:
repetir N veces { Si ESTADO es 1 Aumentar A en uno demás Disminuir A en uno Haz algo con A}
En este caso, el código automodificable sería simplemente una cuestión de reescribir el bucle de esta manera:
repetir N veces { aumentar A en uno Haz algo con A cuando el ESTADO tiene que cambiar { Reemplace el código de operación "aumentar" anterior con el código de operación para disminuir, o viceversa. }}
Tenga en cuenta que el reemplazo de dos estados del código de operación se puede escribir fácilmente como 'xor var en la dirección con el valor "opcodeOf(Inc) xor opcodeOf(dec)"'.
La elección de esta solución debe depender del valor de N y de la frecuencia del cambio de estado.
Supongamos que se debe calcular un conjunto de estadísticas, como promedio, valores extremos, ubicación de los valores extremos, desviación estándar, etc., para un conjunto de datos grande. En una situación general, puede haber una opción para asociar pesos con los datos, de modo que cada x i se asocie con un w i y, en lugar de probar la presencia de pesos en cada valor de índice, podría haber dos versiones del cálculo, una para usar con pesos y otra que no, con una prueba al comienzo. Ahora, considere otra opción: que cada valor pueda tener asociado un booleano para indicar si ese valor se debe omitir o no. Esto se puede manejar produciendo cuatro lotes de código, uno para cada permutación y los resultados de hinchazón del código. Alternativamente, las matrices de pesos y omisiones se pueden fusionar en una matriz temporal (con cero pesos para los valores que se deben omitir), a costa del procesamiento y aún así hay hinchazón. Sin embargo, con la modificación del código, a la plantilla para calcular las estadísticas se le puede agregar, según corresponda, el código para omitir valores no deseados y para aplicar pesos. No habría pruebas repetidas de las opciones y se accedería a la matriz de datos una sola vez, como también a las matrices de peso y omisión, si estuvieran involucradas.
El código automodificable es más complejo de analizar que el código estándar y, por lo tanto, se puede utilizar como protección contra la ingeniería inversa y el pirateo de software . El código automodificable se utilizó para ocultar las instrucciones de protección de copia en programas basados en disco de la década de 1980 para sistemas como IBM PC compatibles y Apple II . Por ejemplo, en una IBM PC, la instrucción de acceso a la unidad de disqueteint 0x13
no aparecería en la imagen del programa ejecutable, sino que se escribiría en la imagen de memoria del ejecutable después de que el programa comenzara a ejecutarse.
El código automodificable también es utilizado a veces por programas que no quieren revelar su presencia, como los virus informáticos y algunos shellcodes . Los virus y shellcodes que utilizan código automodificable lo hacen principalmente en combinación con código polimórfico . La modificación de un fragmento de código en ejecución también se utiliza en ciertos ataques, como los desbordamientos de búfer .
Los sistemas de aprendizaje automático tradicionales tienen un algoritmo de aprendizaje fijo y preprogramado para ajustar sus parámetros . Sin embargo, desde la década de 1980, Jürgen Schmidhuber ha publicado varios sistemas automodificables con la capacidad de cambiar su propio algoritmo de aprendizaje. Evitan el peligro de autoreescrituras catastróficas al asegurarse de que las automodificaciones sobrevivirán solo si son útiles de acuerdo con una función de aptitud , error o recompensa dada por el usuario . [14]
El núcleo Linux hace un uso amplio de código automodificable; lo hace para poder distribuir una única imagen binaria para cada arquitectura principal (por ejemplo, IA-32 , x86-64 , ARM de 32 bits , ARM64 ...) al tiempo que adapta el código del núcleo en la memoria durante el arranque dependiendo del modelo de CPU específico detectado, por ejemplo, para poder aprovechar las nuevas instrucciones de la CPU o para solucionar errores de hardware. [15] [16] En menor medida, el núcleo DR-DOS también optimiza secciones críticas para la velocidad de sí mismo en el momento de la carga dependiendo de la generación del procesador subyacente. [10] [11] [nb 2]
De todos modos, a un nivel meta , los programas aún pueden modificar su propio comportamiento cambiando datos almacenados en otro lugar (ver metaprogramación ) o mediante el uso de polimorfismo .
El núcleo Synthesis presentado en la tesis doctoral de Alexia Massalin [17] [18] es un pequeño núcleo Unix que adopta un enfoque estructurado , o incluso orientado a objetos , para el código automodificable, donde el código se crea para quajects individuales , como manejadores de archivos. La generación de código para tareas específicas permite que el núcleo Synthesis (como lo haría un intérprete JIT) aplique una serie de optimizaciones como el plegado constante o la eliminación de subexpresiones comunes .
El núcleo Synthesis era muy rápido, pero estaba escrito completamente en lenguaje ensamblador. La falta de portabilidad resultante ha impedido que las ideas de optimización de Massalin fueran adoptadas por cualquier núcleo de producción. Sin embargo, la estructura de las técnicas sugiere que podrían ser capturadas por un lenguaje de nivel superior , aunque uno más complejo que los lenguajes de nivel medio existentes. Un lenguaje y un compilador de este tipo podrían permitir el desarrollo de sistemas operativos y aplicaciones más rápidos.
Paul Haeberli y Bruce Karsh se han opuesto a la "marginación" del código automodificable y de la optimización en general, en favor de la reducción de los costos de desarrollo. [19]
En arquitecturas sin caché de datos e instrucciones acopladas (por ejemplo, algunos núcleos SPARC , ARM y MIPS ), la sincronización de caché debe ser realizada explícitamente por el código modificador (vaciar el caché de datos e invalidar el caché de instrucciones para el área de memoria modificada).
En algunos casos, las secciones cortas de código que se modifica a sí mismo se ejecutan más lentamente en los procesadores modernos. Esto se debe a que un procesador moderno generalmente intentará mantener bloques de código en su memoria caché. Cada vez que el programa reescribe una parte de sí mismo, la parte reescrita debe cargarse nuevamente en la memoria caché, lo que genera un ligero retraso si el código modificado comparte la misma línea de caché con el código modificador, como es el caso cuando la dirección de memoria modificada se encuentra a unos pocos bytes de la del código modificador.
El problema de invalidación de caché en los procesadores modernos generalmente significa que el código automodificable seguirá siendo más rápido solo cuando la modificación ocurra con poca frecuencia, como en el caso de un cambio de estado dentro de un bucle interno. [ cita requerida ]
La mayoría de los procesadores modernos cargan el código de la máquina antes de ejecutarlo, lo que significa que si se modifica una instrucción que está demasiado cerca del puntero de instrucción , el procesador no lo notará, sino que ejecutará el código tal como estaba antes de ser modificado. Véase cola de entrada de precarga (PIQ). Los procesadores de PC deben manejar el código que se modifica automáticamente correctamente por razones de compatibilidad con versiones anteriores, pero están lejos de ser eficientes en hacerlo. [ cita requerida ]
Debido a las implicaciones de seguridad que tiene el código que se modifica a sí mismo, todos los principales sistemas operativos tienen cuidado de eliminar dichas vulnerabilidades a medida que se descubren. La preocupación no suele ser que los programas se modifiquen a sí mismos intencionalmente, sino que puedan ser modificados de forma maliciosa mediante un exploit .
Un mecanismo para evitar la modificación de código malicioso es una característica del sistema operativo llamada W^X (por "write xorexecute "). Este mecanismo prohíbe a un programa hacer que cualquier página de memoria sea tanto escribible como ejecutable. Algunos sistemas impiden que una página escribible se cambie a ejecutable, incluso si se elimina el permiso de escritura. [ cita requerida ] Otros sistemas proporcionan una especie de " puerta trasera ", que permite que múltiples asignaciones de una página de memoria tengan diferentes permisos. Una forma relativamente portátil de evitar W^X es crear un archivo con todos los permisos y luego asignar el archivo a la memoria dos veces. En Linux, se puede usar una bandera de memoria compartida SysV no documentada para obtener memoria compartida ejecutable sin necesidad de crear un archivo. [ cita requerida ]
El código que se modifica a sí mismo es más difícil de leer y mantener porque las instrucciones en la lista del programa fuente no son necesariamente las instrucciones que se ejecutarán. La modificación automática que consiste en la sustitución de punteros de función puede no ser tan críptica, si está claro que los nombres de las funciones que se llamarán son marcadores de posición para las funciones que se identificarán más adelante.
El código automodificable se puede reescribir como código que prueba una bandera y se ramifica a secuencias alternativas según el resultado de la prueba, pero el código automodificable normalmente se ejecuta más rápido.
El código automodificable entra en conflicto con la autenticación del código y puede requerir excepciones a las políticas que exigen que todo el código que se ejecuta en un sistema esté firmado.
El código modificado debe almacenarse separado de su forma original, lo que entra en conflicto con las soluciones de gestión de memoria que normalmente descartan el código en la RAM y lo vuelven a cargar desde el archivo ejecutable según sea necesario.
En los procesadores modernos con un pipeline de instrucciones , el código que se modifica a sí mismo con frecuencia puede ejecutarse más lentamente si modifica instrucciones que el procesador ya ha leído desde la memoria en el pipeline. En algunos de estos procesadores, la única forma de garantizar que las instrucciones modificadas se ejecuten correctamente es vaciar el pipeline y volver a leer muchas instrucciones.
El código automodificable no se puede utilizar en absoluto en algunos entornos, como los siguientes:
REP MOVSW
instrucciones de 16 bits ("copiar palabras") en la imagen de tiempo de ejecución del núcleo por instrucciones de 32 bits REP MOVSD
("copiar palabras dobles") al copiar datos de una ubicación de memoria a otra (y la mitad del recuento de repeticiones necesarias) para acelerar las transferencias de datos del disco. Se tienen en cuenta los casos extremos , como los recuentos impares. [10] [11]El SSEC fue el primer ordenador operativo capaz de tratar sus propias instrucciones almacenadas exactamente como datos, modificándolas y actuando sobre el resultado.
[…] Originalmente,
la reescritura binaria
estaba motivada por la necesidad de cambiar partes de un programa durante la ejecución (por ejemplo, la aplicación de parches en tiempo de ejecución en el
PDP-1
en los años 1960) […](36 páginas)
[…] Además de buscar una instrucción, el
Z80
utiliza la mitad del ciclo para
refrescar
la
RAM dinámica
. […] dado que el Z80 debe dedicar la mitad de cada ciclo
de búsqueda de instrucciones
a realizar otras tareas, no tiene tanto tiempo para buscar un
byte de instrucción
como para buscar un byte de datos. Si uno de los
chips de RAM
en la ubicación de memoria a la que se accede es un poco lento, el Z80 puede obtener el patrón de bits incorrecto cuando busca una instrucción, pero obtener el correcto cuando lee datos. […] la prueba de memoria incorporada no detectará este tipo de problema […] es estrictamente una prueba de lectura/escritura de datos. Durante la prueba, todas las instrucciones se obtienen de la
ROM
, no de la RAM […] lo que da como resultado que el
H89
pase la prueba de memoria pero siga funcionando de forma errática en algunos programas. […] Este es un programa que prueba la memoria reubicándose a través de la RAM. Mientras lo hace, la CPU imprime la dirección actual del programa en el
CRT
y luego obtiene la instrucción en esa dirección. Si los IC de RAM están bien en esa dirección, la CPU reubica el programa de prueba en la siguiente ubicación de memoria, imprime la nueva dirección y repite el procedimiento. Pero, si uno de los IC de RAM es lo suficientemente lento como para devolver un patrón de bits incorrecto, la CPU malinterpretará la instrucción y se comportará de forma impredecible. Sin embargo, es probable que la pantalla se bloquee mostrando la dirección del IC defectuoso. Esto reduce el problema a ocho IC, lo que es una mejora con respecto a tener que verificar hasta 32. […] El […] programa realizará una prueba de gusano enviando una instrucción RST 7 (RESTART 7) desde el extremo inferior de la memoria hasta la última dirección de trabajo. El resto del programa permanece estacionario y se encarga de mostrar la ubicación actual del comando RST 7 y su
reubicación
. Por cierto, el programa se llama prueba
de gusano
porque, a medida que la instrucción RST 7 avanza por la memoria, deja un
rastro viscoso
de
NOP
(NO OPERATION). […]