.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 0En 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.
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.
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 axxdque 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.-eindica 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.
El programa
topsigue mostrando el nombre original del programa, yhtopse 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/exeque 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
getoptdesde ensamblador, no tiene sentido ese ejemplo y no aportaría mucho más que la versión C que ya hemos visto.
■
