Parámetros Línea de Comandos. Ensamblador
SOLUCIONARIO
Parámetros Línea de Comandos. Ensamblador
2026-03-22
Por
Occam's Razor

    .intel_syntax noprefix  
    .global _start
    .extern printf
    .extern exit

_start:
    // Muestra número de parámetros
    mov rdi, OFFSET fmt1
    mov rsi, [RSP]        # Primer valor en la pila es argc
    call printf

    // Muestra parametros
    mov r12, rsp          # r12 apunta a la pila (argc)
b0:
    // En la primera iteración saltamos argc y apuntamos a argv[0]
    add r12, 8

    mov rdi, OFFSET fmt2
    mov rsi, [i]
    mov rdx, [r12]
    call printf

    inc word ptr [i]      # ++

    // if (i != argc) continue
    mov r11, [i]
    cmp r11, [rsp]
    jne b0

    // Si hemos recibo 1 parámetro mostramos mensaje
    cmp QWORD PTR [rsp], 2
    je mensaje
    
    // Sino mostramos error y terminamos
    mov  rdi, OFFSET fmt3
    call printf

    // Terrmina con error
    mov  rax, 1
    jmp  salir
    
mensaje:
    //  Imprime mensaje para un parámetro
    mov rdi, OFFSET fmt4
    mov rax, 1           # Parámetro 1
    mov rsi, [rsp + rax*8 + 8]
    call printf

    // Termina sin error
    mov rax, 0
    
salir:  
    call exit
    
    .section .rodata
fmt1:   .asciz "%d Parametros\n"
fmt2:   .asciz "Parametro %d : %s\n" ;
fmt3:   .asciz "Solo un parametro por favor\n"
fmt4:   .asciz "Hola %s\n"
    
    .section .data
i:  .word 0

En ensamblador las cosas son un poco diferentes, aunque sienta muy bien tener el control absoluto de lo que sucede. Cuando programamos en ensamblador no tenemos todo ese código del C Run-time que llama a main como una función normal, con los parámetros en rdi, rsi,… Sin embargo, recibimos lo que el kernel da al proceso al iniciar su ejecución con execve.

Recordemos que la llamada al sistema execve nos permite especificar los argumentos que queremos pasar al programa así como las variables de entorno. El kernel, pone toda esta información en la pila que crea para el nuevo proceso antes de darle el control. De tal forma que la pila tiene la siguiente estructura cuando el proceso inicia su ejecución:

| Datos    |
| NULL     |
| ....     |
| env[1]   |
| env[0]   | RSP + 8 *argc + 8
| NULL     |
| ....     |
| argv[1]  | RSP + 16
| argv[0]  | RSP + 8
|  argc    | RSP

Es decir, el primer valor en la pila es argc el número de argumentos recibidos a través de la línea de comandos, y a partir de hay se encuentran los punteros a todos los demás argumentos. La lista de argumentos termina con un valor NULL, y a continuación nos encontramos la lista de variables de entorno pasadas al proceso desde execve. Esta lista también termina con NULL. Tras las variables de entorno hay algunos datos más, bastante interesantes pero que no son relevantes en este momento.

SOY NOVATO

Los procesos en Linux están aislados los unos de los otros, es decir, ningún proceso puede leer/escribir la memoria de otro directamente y por lo tanto el kernel es el único que puede generar pila de un proceso que va a ejecutarse. Ten en cuenta que esto es muy distinto a lo que sucede con el denominado userland exec. En ese caso el proceso crea una pila y carga un nuevo binario en su propia memoria para finalmente cederle el control, pero todo eso ocurre en el mismo espacio de direccionamiento del proceso que lo realiza

 

Sabiendo todo esto, el programa es bastante sencillo. Simplemente imprime cadenas usando los punteros que hemos recibido en la pila como parámetros a printf. Un par de detalles por si no os habéis dado cuenta o estáis empezando con el ensamblador.

Para imprimir los argumentos hemos escrito el bucle obvio, el cual itera sobre una variable contador hasta que alcance el valor deseado, en este caso argc. En este caso, tenemos un valor de guarda en la pila, un NULL, que nos indica que cuando leamos ese valor, hemos alcanzado el final de la lista. Teniendo esto en cuenta, podríamos reescribir el bucle de una forma un poco más sencilla, como por ejemplo esta:

    mov  r12, rsp          # r12 apunta a la pila (argc)
    xor  r13, r13          # Contador para imprimir el índice del argumento
b0:
    add  r12, 8 
    cmp  QWORD PTR [r12], 0
    je   cont
    
    mov  rdi, OFFSET fmt2
    mov  rsi, r13
    mov  rdx, [r12]
    call printf
    inc  r13
    jmp  b0
cont:   

Más corto y compacto.

Por último, la parte del programa que imprime el mensaje si recibe exactamente un parámetro, usa direccionamiento indexado para acceder directamente al parámetro que necesita, en este caso el valor de índice 1. He escrito el código de forma que se pueda utilizar para acceder a cualquier parámetro. Aquí lo pongo de nuevo:

    mov rax, 1          
    mov rsi, [rsp + rax*8 + 8]

Es esas líneas, rax contiene el índice del argumento al que queremos acceder. En este caso tan sencillo, podríamos, simplemente, haber escrito:

  mov rsi, [rsp + 16]

Pero usando el direccionamiento indexado (ya hablamos de él en el capítulo sobre bucles), podéis ver como acceder a un array en memoria de forma sencilla usando una única instrucción (una vez el índice se encuentre en un registro). Observar también que hemos escrito nuestra expresión para utilizar el indexado estándar, es decir, índice 0 es el nombre del programa, índice 1 es el primer parámetro,…

Modificando el nombre del programa

Ahora que estamos en ensamblador podemos ver en detalle como funciona eso de modificar el nombre del programa sobre-escribiendo argv[0]. Para ello, vamos a utilizar un pequeño programa de ejemplo que nos permita examinar la memoria del proceso fácilmente y localizar cada uno de los componentes.

    .intel_syntax noprefix  
    .global _start
    .extern printf
    .extern getpid
    .extern exit
    .extern getchar

_start:
    call getpid
    mov rdi, OFFSET fmt1 # Muestra PID
    mov rsi, rax
    call printf
    
    mov rdi, OFFSET fmt2 # Muestra valor de RSP 
    mov rsi, rsp
    call printf 
    
    mov rdi, OFFSET fmt3 # Muestra el puntero a argv[0]
    mov rsi, [rsp + 8]
    mov rdx, rsi
    call printf

    
salir:
    call getchar
    call exit
    
    .section .rodata
fmt1:   .asciz "Soy %ld\n"
fmt2:   .asciz "Mi pila está en [%p]\n"
fmt3:   .asciz "Argumento 0 [%p] : %s\n"

El programa simplemente imprime su PID (para que no tengamos que andar buscándolo), el valor del puntero de pila RSP y luego el puntero y el valor de argv[0]. Si lo ejecutamos, obtendremos una salida como esta:

$ ./test-asm 11 22 33 44
Soy 688322
Mi pila está en [0x7ffeba5b82d0]
Argumento 0 [0x7ffeba5b9301] : ./test-asm

Ahora, con el PID, podemos comprobar el mapa de memoria de ese proceso y verificar que la cadena con el nombre del programa está en la pila:

$ cat /proc/688322/maps | grep stack
7ffeba599000-7ffeba5ba000 rw-p 00000000 00:00 0                          [stack]

Si ahora comprobamos la entrada cmdline en /proc, obtendremos lo siguiente:

$ cat /proc/688322/cmdline | xxd
00000000: 2e2f 7465 7374 2d61 736d 0031 3100 3232  ./test-asm.11.22
00000010: 0033 3300 3434 00                        .33.44.

Estos son todos los parámetros que hemos pasado al programa uno tras otro. Observad el terminador de cadena \0 después de cada cadena. Los punteros a los argumentos que recibimos en la pila apuntan a esta memoria, la cual, también se encuentra en la pila, exactamente en la dirección 0x7ffeba5b9301 para este ejemplo.

Para comprobar esto podemos usar una de las herramientas desarrolladas en un fantástico artículo de Occam’s Razor que os recomendamos leer ;) y que nos permite volcar la memoria de un proceso en ejecución, o el comando dd. El resultado es el mismo:

$ dd if=/proc/688322/mem bs=1 count=$((0x2000)) skip=$((0x7ffeba5b82d0)) | xxd
$ uhg 688322 $((0x7ffeba5b82d0)) $((0x1900)) | xxd

Nota:Usando dd podemos poner el tamaño de memoria que queramos, cuando no pueda leer más parará. ug usa otro método para leer la memoria y si el valor es demasiado grande, no podrá leer los datos. El valor máximo que podemos usar es la diferencia entre el final del segmento de pila reportado por /proc/PID/maps y el puntero que pasamos a ug, que en el ejemplo anterior es 0x1d30.

Echemos un vistazo a la primera parte del volcado:

00000000: 0500 0000 0000 0000 0193 5bba fe7f 0000  ..........[.....
00000010: 0c93 5bba fe7f 0000 0f93 5bba fe7f 0000  ..[.......[.....
00000020: 1293 5bba fe7f 0000 1593 5bba fe7f 0000  ..[.......[.....
00000030: 0000 0000 0000 0000 1893 5bba fe7f 0000  ..........[.....
00000040: 2893 5bba fe7f 0000 7a93 5bba fe7f 0000  (.[.....z.[.....
00000050: 8d93 5bba fe7f 0000 a093 5bba fe7f 0000  ..[.......[.....
00000060: b993 5bba fe7f 0000 f393 5bba fe7f 0000  ..[.......[.....
00000070: 0994 5bba fe7f 0000 2594 5bba fe7f 0000  ..[.....%.[.....
00000080: 3794 5bba fe7f 0000 6694 5bba fe7f 0000  7.[.....f.[.....
00000090: 8994 5bba fe7f 0000 aa94 5bba fe7f 0000  ..[.......[.....
000000a0: bd94 5bba fe7f 0000 cc94 5bba fe7f 0000  ..[.......[.....
000000b0: f494 5bba fe7f 0000 1995 5bba fe7f 0000  ..[.......[.....
000000c0: 2695 5bba fe7f 0000 3b95 5bba fe7f 0000  &.[.....;.[.....
000000d0: 5d95 5bba fe7f 0000 8d95 5bba fe7f 0000  ].[.......[.....
000000e0: 9d95 5bba fe7f 0000 ae95 5bba fe7f 0000  ..[.......[.....
000000f0: 2b9d 5bba fe7f 0000 449d 5bba fe7f 0000  +.[.....D.[.....
00000100: 789d 5bba fe7f 0000 8b9d 5bba fe7f 0000  x.[.......[.....
00000110: 999d 5bba fe7f 0000 ba9d 5bba fe7f 0000  ..[.......[.....
00000120: d19d 5bba fe7f 0000 e59d 5bba fe7f 0000  ..[.......[.....
00000130: ef9d 5bba fe7f 0000 fc9d 5bba fe7f 0000  ..[.......[.....
00000140: 049e 5bba fe7f 0000 0f9e 5bba fe7f 0000  ..[.......[.....
00000150: 209e 5bba fe7f 0000 3f9e 5bba fe7f 0000   .[.....?.[.....

Recordad que esto es la pila. La primera dirección que aparece es la parte baja de la pila, hacia donde crece. Todo lo que viene después está en direcciones más altas de memoria, es decir, podemos ver el valor del offset en la primera columna, como el número que debemos sumar a RSP para acceder a esa información.

El primer valor que encontramos es 5, eso si, almacenado como un qword (8 bytes) little endian. Este es argc. Como sabemos, lo que sigue son los punteros a los parámetros, un NULL y luego los punteros a las variables de entorno.

SOY NOVATO

Leer volcados hexadecimales es solo una cuestión de práctica. En este caso es bastante sencillo puesto que todos los valores son de 8 bytes, lo que significa que por cada línea volcada por xxd (16 bytes), tenemos 2 valores y sabemos que los procesadores Intel son Little Endian, por lo que los punteros los tenemos que leer al revés. Es bueno acostumbrarse a esto, pero si os resulta muy complicado, siempre podéis decir a xxd que os haga la conversión usando los flags -g 8 -e.

-g 8, agrupa los datos en bloques de 8 bytes, que para este caso es perfecto. -e indica que los datos están en formato Little Endian y hace que los imprima en el orden correcto.

 

Podéis ver el NULL que marca el final de los argumentos en el offset 0x030. Y el final de las variables de entorno en 0x198. En esa parte de la memoria podemos ver un montón de punteros a la pila. Tomemos el primer valor por ejemplo:

00000000: 0500 0000 0000 0000 0193 5bba fe7f 0000  ..........[.....

Teniendo en cuenta que estamos en una máquina little endian, la segunda entrada en la pila (es decir argv[0]) está en 0x7ffeba5b9301, que coincide con la salida de nuestro programa. Veamos ahora que hay en esa posición de memoria. Puesto que hemos comenzado el volcado en la dirección 0x7ffeba5b82d0, para calcular el offset en nuestro volcado hexadecimal a los datos asociados al puntero 0x7ffeba5b9301 (el valor de argv[0] reportado por nuestro programa), tendremos que restar esos dos valores:

$ printf %x <<< echo $((0x7ffeba5b9301 - 0x7ffeba5b82d0))
1031

Si miramos lo que hay en ese offset:

00001030: 002e 2f74 6573 742d 6173 6d00 3131 0032  ../test-asm.11.2
00001040: 3200 3333 0034 3400 5348 454c 4c3d 2f62  2.33.44.SHELL=/b
00001050: 696e 2f62 6173 6800 5345 5353 494f 4e5f  in/bash.SESSION_
00001060: 4d41 4e41 4745 523d 6c6f 6361 6c2f 6c69  MANAGER=local/li

Vemos nuestros argumentos uno tras otro, seguidos de las variables de entorno que recibe el programa. Podéis comprobar el resto de punteros en la primera parte del volcado y veréis como coinciden con los argumentos y variables de entornos.

Pues bien, esta parte de la pila es la que se expone en /proc/PID/cmdline y de la misma forma, la zona conteniendo las variables de entorno se expone en /proc/PID/environ.

$ cat /proc/688322/cmdline | xxd
00000000: 2e2f 7465 7374 2d61 736d 0031 3100 3232  ./test-asm.11.22
00000010: 0033 3300 3434 00                        .33.44.

Y esta es la información que usan programas como ps para mostrar el nombre del programa y los argumentos asociado a un determinado proceso. Así que si escribimos en esa posición de memoria, o si lo preferís, sobrescribimos argv[0], programas como htop o ps comenzarán a mostrar la información que hayamos escrito. Otros como htop siguen mostrando el programa original.

¿SABÍAS QUÉ?

El programa top sigue mostrando el nombre original del programa, y htop se puede configurar para ello. La razón es que hay al menos dos fuentes de información para obtener el nombre del programa a visualizar. La primera es /proc/PID/cmdline, la cual es muy conveniente puesto que en una sola lectura nos permite reconstruir la línea de comandos con todos sus argumentos. La segunda es /proc/PID/exe que es un enlace simbólico al fichero que se ha ejecutado, y ese no se puede modificar, aunque si se puede borrar. Esto último es curioso puesto que el fichero desaparece del disco, pero todavía se puede recuperar, mientras el programa esté funcionando simplemente copiando /proc/PID/exe.

 

Sin embargo, como podéis ver hay unas ciertas limitaciones. Si el programa sobre escribe argv[0] al principio de todo y usa un nombre más largo que el actual, estará escribiendo también los valores de los argumentos que se han pasado y el resultado puede ser fatídico, puesto que los punteros al principio de la pila apuntarán a algún punto en el medio del nombre que hayamos escrito y al intentar parsearlos las cosas pueden ir muy mal.

Pero, si cambiamos el nombre del programa, tras haber procesado todos los parámetros, podremos sobrescribir toda esa zona de memoria con lo que queramos sin problemas (suponiendo que no vamos a volver a acceder a argv[i] de nuevo). Así que, en principio, podríamos dar un nombre tan largo como la longitud total de la línea de comandos recibida incluyendo los espacios. Si escribimos más que ese tamaño, comenzaríamos a sobre escribir las variables de entorno, lo cual puede tener consecuencias dependiendo de lo que haga el programa. Observad que de esta forma también podemos modificar completamente la línea de comandos que reportan programas como ps o htop proporcionado parámetros falsos.

En resumen, podemos modificar el nombre con el que aparece el programa en herramientas como ps, pero debemos tener cuidado con la cantidad de caracteres que escribimos, puesto que los datos de argumentos y variables de entorno se encuentras todos seguidos en memoria. Por eso, en general, no es seguro sobre escribir argv[0] con un nombre más largo que el original.

Veamos como podríamos cambiar nuestro programa para modifcar su nombre:

    .intel_syntax noprefix  
    .global _start
    .extern printf
    .extern getpid
    .extern exit
    .extern getchar

_start:
    call getpid
    mov rdi, OFFSET fmt1 # Imprime PID
    mov rsi, rax        
    call printf
    mov rdi, OFFSET fmt3 # Imprime Puntero de pila RSP
    mov rsi, rsp        
    call printf

    mov rdi, OFFSET fmt2 # Imprime puintero y valor de argv[0]
    mov rsi, [rsp + 8]   
    mov rdx, rsi
    call printf

    mov r11, [rsp + 8]   # Modifica argv[0]
    mov r12, 0x00736d6163636f
    mov [r11], r12 

    
salir:
    call getchar
    call exit
    
    .section .rodata
fmt1:   .asciz "Soy %ld\n"
fmt2:   .asciz "Argumento 0 [%p] : %s\n"
fmt3:   .asciz "Mi pila está en [%p]\n"

Observad que hemos añadido una llamada a getchar para que el programa pause su ejecución y podemos examinarlo con ps o la herramienta que queramos. Sino lo hacemos el programa terminará inmediatamente y no podríamos comprobar si el cambio de nombre funcionó.

En otra ocasión hablaremos de lo que está más allá de las variables de entorno….

SUMARIO

  • En ensamblador recibimos todos los parámetros en la pila como un array de punteros a cadenas de caracteres terminadas en cero.
  • El número de argumentos es el primer valor en la pila y es numérico
  • Podemos modificar el nombre del programa, sobreescribiendo la memoria apuntada por el segundo valor de la pila
  • Si bien podríamos usar getopt desde ensamblador, no tiene sentido ese ejemplo y no aportaría mucho más que la versión C que ya hemos visto.

SOBRE Occam's Razor
Somos una revista libre que intenta explicar de forma sencilla conceptos tecnológicos y científicos de los que no podemos escapar. Al final, todo es más fácil de lo que parece!

 
Tu publicidad aquí :)