Llamando a funciones sin parámetros con parámetros
FENOMENOS EXTRAÑOS
Llamando a funciones sin parámetros con parámetros
2024-06-19
Por
Carolyn Lightrun

En esta entrega de Fenómenos Extraños os traemos el extraño caso de la función sin parámetros que procesa y recibe parámetros… Más inquietante no se puede.

Imaginad llamar a un función normal y corriente que acepta varios parámetros sin pasarle ningún de ellos directamente pero siendo capaces de pasarle los valores que nosotros queramos de forma indirecta. Mola no?. Bueno, lo más seguro es que no se haya entendido nada… Pero que no cunda el pánico, vamos a ver como se arma todo esto paso a paso.

Radiografía de una función con parámetros

Lo primero que vamos a hacer es mirar en el interior de una función con parámetros… Descifraremos su esencia misma… Su naturaleza última… Desnudaremos su alma…. Usea que vamos a ver el ensamblador.

Tomemos como ejemplo a margarita, una función cualquiera que acepta 3 parámetros:

#include <stdio.h>

int margarita (int a, int b, int c) {
  int _a, _b, _c, _d;

  _a = a;
  _b = b;
  _c = c;
  _d = _a + _b + _c;

  return _d;
}

int main () {
  int v1 = margarita (10, 20, 30);
  printf ("%d\n", v1);
}

Si tras compilar este programa, le echamos un ojo a margarita veremos algo como esto:

$ make gf-final
$ objdump -d gf-final | grep -A20 "<margarita>:"
0000000000001139 <margarita>:
    1139:   55                      push   %rbp
    113a:   48 89 e5                mov    %rsp,%rbp
    113d:   89 7d ec                mov    %edi,-0x14(%rbp)
    1140:   89 75 e8                mov    %esi,-0x18(%rbp)
    1143:   89 55 e4                mov    %edx,-0x1c(%rbp)
    1146:   8b 45 ec                mov    -0x14(%rbp),%eax
    1149:   89 45 fc                mov    %eax,-0x4(%rbp)
    114c:   8b 45 e8                mov    -0x18(%rbp),%eax
    114f:   89 45 f8                mov    %eax,-0x8(%rbp)
    1152:   8b 45 e4                mov    -0x1c(%rbp),%eax
    1155:   89 45 f4                mov    %eax,-0xc(%rbp)
    1158:   8b 55 fc                mov    -0x4(%rbp),%edx
    115b:   8b 45 f8                mov    -0x8(%rbp),%eax
    115e:   01 c2                   add    %eax,%edx
    1160:   8b 45 f4                mov    -0xc(%rbp),%eax
    1163:   01 d0                   add    %edx,%eax
    1165:   89 45 f0                mov    %eax,-0x10(%rbp)
    1168:   8b 45 f0                mov    -0x10(%rbp),%eax
    116b:   5d                      pop    %rbp
    116c:   c3                      ret

Veamos que hace todo ese código que acabamos de extraer del programa:

push   %rbp             # Almacena el Frame Pointer (FP) anterior
mov    %rsp,%rbp        # El nuevo FP apunta al ultimo elemento de la pila
mov    %edi,-0x14(%rbp) # Parametro 1 -> RPB - 0x14
mov    %esi,-0x18(%rbp) # Parametro 2 -> RBP - 0x18
mov    %edx,-0x1c(%rbp) # Parametro 3 -> RBP - 0x1A
mov    -0x14(%rbp),%eax # Copia Parametro 1 en EAX
mov    %eax,-0x4(%rbp)  # Almacena EAX en RBP-0x4
mov    -0x18(%rbp),%eax # Copia Parametro 2 en EAX
mov    %eax,-0x8(%rbp)  # Almacena EAX en RBP-0x8
mov    -0x1c(%rbp),%eax # Copia Parámetro 3 en EAX
mov    %eax,-0xc(%rbp)  # Almancena EAX en RBP - 0x0c
mov    -0x4(%rbp),%edx  # Copia Parametro 1 a EDX
mov    -0x8(%rbp),%eax  # Copia Parametro 2 a EAX
add    %eax,%edx        # EAX = Parametro1 + Parametro 2
mov    -0xc(%rbp),%eax  # Copia Parametro 3 a EAX
add    %edx,%eax        # EAX = EAX + Parametro 3
mov    %eax,-0x10(%rbp) # Almanacena Par1+Par2+Par3 en RBP -0x10
mov    -0x10(%rbp),%eax
pop    %rbp
ret

El código es muy sencillo y reproduce exactamente lo que hemos escrito en nuestro programa C… Pero lo que nos interesa es la pinta con la que se queda la pila

La pila de una función con parámetros

Según el ensamblador que acabamos de ver, la pila de la función antes de retornar tendrá esta pinta:

            | DIRECCIÓN RETORNO | 
            | BP ANTIGUO
RSP, RBP -> | 
RBP-0x04 -> | Variable _a
RBP-0x08 -> | Variable _b
RBP-0x0c -> | Variable _c
RBP-0x10 -> | Variable _d
RBP-0x14 -> | Copia temporal Parametro 1
RBP-0x18 -> | Copia Temporal Parametro 2
RBP-0x1c -> | Copia Temporal Parametro 3

Como podéis ver, el compilador genera las variables locales de la función en memoria y además copia de forma temporal los valores recibidos como parámetros también en la pila.

Cuando la función retorna, lo único que hace es restaurar el valor de BP y retornar…. O como se suele decir “Lo que se escribe en la pila se queda en la pila”.

Conozcamos a Casper

Es hora de introducir a casper que es una función muy parecida a margarita, pero que calcula otra cosa y a la que le vamos a dar un toque fantasmal:

int casper (int a, int b, int c) {
  int _a, _b, _c, _d;

  _d = 2 * _a + 2 * _b + 2 *_c;

  return _d;
}

Si ahora modificamos nuestra función main de la siguiente forma:

int main () {
  int (*f)() = (int (*)(void))(casper);
  int v1 = margarita (10, 20, 30);
  int v2 = f ();
  int v3 = casper (10, 20, 30);
  printf ("v1 : %d\n", v1);
  printf ("v2 : %d\n", v2);
  printf ("v2 : %d\n", v3);
}

Cual pensáis que es el valor de v2? … Pues sí, el mismo valor que v3, es decir, hemos ejecutado casper sin pasarle parámetros pero haciendo que use los valores que nosotros queramos…. Super inquietante.

Observad que hemos tenido que usar un cast a una función sin parámetros para que el compilador no genere un error. Esa es la primera línea de la función main.

Radiografía de casper

Vamos a ver que pinta tiene casper para entender que esta pasando. Aunque imagino que todos vosotros ya lo sabréis.

$ objdump -d gf-final | grep -A 16 "<casper>:"
000000000000116d <casper>:
    116d:   55                      push   %rbp
    116e:   48 89 e5                mov    %rsp,%rbp
    1171:   89 7d ec                mov    %edi,-0x14(%rbp)
    1174:   89 75 e8                mov    %esi,-0x18(%rbp)
    1177:   89 55 e4                mov    %edx,-0x1c(%rbp)
    117a:   8b 55 fc                mov    -0x4(%rbp),%edx
    117d:   8b 45 f8                mov    -0x8(%rbp),%eax
    1180:   01 c2                   add    %eax,%edx
    1182:   8b 45 f4                mov    -0xc(%rbp),%eax
    1185:   01 d0                   add    %edx,%eax
    1187:   01 c0                   add    %eax,%eax
    1189:   89 45 f0                mov    %eax,-0x10(%rbp)
    118c:   8b 45 f0                mov    -0x10(%rbp),%eax
    118f:   5d                      pop    %rbp
    1190:   c3                      ret

Y una rápida reconstrucción de la pila para esta función nos dará:

            | DIRECCIÓN RETORNO | 
            | BP ANTIGUO
RSP, RBP -> | 
RBP-0x04 -> | ???
RBP-0x08 -> | ???
RBP-0x0c -> | ???
RBP-0x10 -> | ???
RBP-0x14 -> | Copia temporal Parametro 1
RBP-0x18 -> | Copia Temporal Parametro 2
RBP-0x1c -> | Copia Temporal Parametro 3

Y puesto que hemos llamado a las dos funciones de forma consecutiva, el contenido de las posiciones con ??? no cambia de llamada a llamada, ya que, como indicamos, al terminar las funciones no se limpia la pila… simplemente se restaura el valor de RSP y RBP de forma que se descarte cualquier valor temporal usado durante la función.

So observáis cuidadosamente veréis que el valor de RBP-0x10 se siguie manteniendo, el cual se corresponde con la variable local _d de margarita. Lo que significa que podemos añadir más valores que mantener entre llamas a funciones.

Llamadas más sofisticadas

En nuestro ejemplo anterior hemos usado una única función para pasar los parámetros a nuestra función fantasma, pero podemos usar más funciones para pasar los valores en distintos pasos y además precalcular valores intermedios.. si bien tenemos que tener un poco de cuidado.

Veamos un ejemplo:

int margarita (int a, int b, int c) {
  int _a, _b, _c, _d;

  _a = a;
  _b = _b;
  _c = _c;
  _d = _d + _a;

  return _d;
}

int azucena (int a, int b, int c) {
  int _a, _b, _c, _d;

  _a = _a;
  _b = b;
  _c = _c;
  _d = _d + b;
  return _d;
}

int hortensia (int a, int b, int c) {
  int _a, _b, _c, _d;

  _a = _a;
  _b = _b;
  _c = c;
  _d = _d + c;
  return _d;
}

int casper (int a, int b, int c) {
  int _a, _b, _c, _d;

  _d = _d + 2 * (_a +  _b + _c);
  return _d;
}
int main () {
  int (*f)() = (int (*)())(casper);
  int v1;
  v1 = margarita (4, 20, 30); 
  v1 = azucena   (10, 7, 30);
  v1 = hortensia (10, 20, 5);
  int v2 = f ();

  printf ("v2 : %d\n", v2);

Como podéis ver en este ejemplo usamos tres funciones para pasar, uno a uno los parametros que necesitamos, y además utilizamos cada una de las funciones para realizar parte de los cálculos (vamos acumulando valores en _d).

Limitaciones

Si os habéis fijado en el código de la sección anterior ya os habréis dado cuenta de algunas limitaciones en la forma de utilizar esto. Vamos a explicarlas en detalle.

La primera es que tenemos que reasignar todas las variables locales que queramos mantener entre llamadas ya que de lo contrario, el compilador pensará que solo necesitamos una variable local, y optimizará el código para eso, resultando en las tres funciones escribiendo en el mismo offset en la pila. Podéis eliminar las asignaciones y ver el ensamblador que obtenemos.

La segunda limitación y quizás más importante es que no podemos llamar a funciones demasiado diferentes entre las llamadas a las funciones que almacenan los parámetros y la función final. La razón es que esas funciones sobre escribirán la pila y destruirán nuestras variables locales. Si por ejemplo, podemos un simple printf entre una llamada y otra, obtendremos un resultado incorrecto.

Podemos usar funciones con distintas signaturas, siempre y cuando se preserve la parte inicial de la pila… en otras palabras, podemos añadir más variables globales si queremos pero no podemos eliminarlas. Si vamos a usar 3 parámetros, todas las funciones que llamemos tendrán que preservar al menos las 3 primeras variables locales.

Finalmente, a veces el resultado no es el que esperamos. Cuando esto sucede suele ser porque el compilador ha optimizado el código de la función de tal forma que alguno de los valores se ha reutilizado. Para solucionar el problema podéis intentar desactivar las optimizaciones para las funciones o modificarlas de forma que el compilador crea que necesita esos valores.

Y todo esto pa’qué?

Bueno, el motivo principal de todo esto es para aprender como funcionan las llamadas a funciones y la pila. Lo segundo que podemos aprender de todo esto es la razón por la que suceden esos bugs en los que el mismo código, la primera vez falla, pero la segunda funciona bien (la regla de oro es siempre inicializar tus variables). Finalmente, esta parece una forma curiosa de ofuscación ya que, a priori, en un análisis estático, el código va a parecer incompleto o que realiza unos cálculos diferentes a los que realmente está haciendo.

Header Image Credits: Ryan Miguel Capili

SOBRE Carolyn Lightrun
Carolyn posee una sensibilidad especial para entran en contacto con el más allá. Cuando programa, las cosas más extrañas ocurren y Carolyn ha dedicado su carrera a investigar estos inquietantes fenómenos y encontrar la explicación lógica tras ellos.

 
Tu publicidad aquí :)