Inseguras mis polainas

Estaba yo bien tranquilo escribiendo mi Rust, cuando me di cuenta de que para implementar un skip list quizá necesite usar lo que en el lenguaje le llaman apuntadores en bruto (raw pointers) así que decidí indagar al respecto.

Estos se diferencían de las referencias normales (&T y &mut T) en que no obedecen las reglas de acceso ni tiempos de vida que garantiza el compilador. Para hacer un poco de memoria, las reglas son las siguientes:

  • Toda referencia &T o &mut T apunta a memoria válida (la memoria referenciada está reservada y representa un valor válido del tipo).
  • En cualquier momento, para una variable x se cumple una y solo una de las siguientes afirmaciones:
    • Solo existe una referencia mutable (&mut x)
    • Existe cualquier cantidad de referencias inmutables (&x)

Estas reglas son verificadas por el compilador y nos evitan introducir condiciones de carrera, apuntadores nulos, y otros errores. Sin embargo a veces es necesario evitar que sea el compilador quien las verifique en pos de poder nosotros hacer alguna operación que va más allá de las reglas del bien y del mal, quedando en nuestras manos la responsabilidad de verificar que el código sea correcto.

Lo que haré a continuación será incumplir las reglas y observar la clase de horrores que pueden suceder.

Lo básico

Primero, consideremos tratar de romper las reglas usando referencias normales de Rust:

let mut x = 5;
let y = &mut x; // creamos una referencia mutable a x
let z = &x; // creamos una referencia inmutable

*y += 1; // trato de modificar el valor en x

El compilador tiene la amabilidad de rechazar mi código con el siguiente error:

error[E0502]: cannot borrow `x` as immutable because it is also borrowed as mutable
 --> cosa.rs:4:12
  |
3 |    let y = &mut x; // creamos una referencia mutable a x
  |            ------ mutable borrow occurs here
4 |    let z = &x; // creamos una referencia inmutable
  |            ^^ immutable borrow occurs here
5 |
6 |    *y += 1;
  |    ------- mutable borrow later used here

Y con justa razón, estoy tratando de crear una referencia inmutable mientras una referencia mutable aun está viva, lo cual incumple las reglas. Ahora trataré de hacerlo usando apuntadores en bruto (*mut T y *const T):

let mut x = 5;
let write = &mut x as *mut i32;  // un apuntador en bruto mutable
let read = &x as *const i32;  // un apuntador en bruto inmutable

*write += 1;  // modificamos el valor usando uno de los apuntadores

println!("{}", *read);  // mostramos el valor usando la otra ¡Cuidado!

Pero el compilador igual me retacha alegando que

error[E0133]: dereference of raw pointer is unsafe and requires unsafe function or block
 --> cosa.rs:6:4
  |
6 |    *write += 1;
  |    ^^^^^^^^^^^ dereference of raw pointer
  |
  = note: raw pointers may be NULL, dangling or unaligned; they can violate aliasing rules and cause data races: all of these are undefined behavior

, y es verdad, pues una de las ideas de diseño en Rust es que si existe un problema relacionado con el manejo de la memoria este solo pueda estar en un lugar fácil de detectar en el código, para lo cual nos solicita de la manera más atenta el uso de la palabra unsafe y me advierte de los riesgos. Sin embargo la vida es muy corta para seguir las reglas y procederé sin cautela hasta donde pueda llegar:

let mut x = 5;
let write = &mut x as *mut i32;
let read = &x as *const i32;

unsafe {
   // este bloque señala código potencialmente peligroso pues el
   // compilador no va a garantizar que `write` apunta a memoria válida
   // sin embargo yo se en este caso que sí lo hace
   *write += 1;
}

// ¡Cuidado aquí! Aunque en este caso el resultado esperado sí es 6
// estoy leyendo con un apuntador memoria que modifiqué con otro.
// En código real no darse cuenta de esto puede llevar a bugs difíciles
// de depurar aunque los apuntadores sí sean válidos.
println!("{}", unsafe {
   *read
});

Al ejecutar el código anterior obtenemos un satisfactorio, pero potencialmente inseguro, 6. Como yo lo que quiero es ver el mundo arder, iré más lejos y forzaré el código para que mis apuntadores realmente apunten a memoria inválida:

// Primero crearé los dos apuntadores en este scope para que vivan
// durante el resto del código
let write;
let read;

{
   // este scope artificial hará que la variable `x` deje de ser válida
   // al final del bloque
   let mut x = 5;

   // es necesario asignar los apuntadores mientras `x` vive
   write = &mut x as *mut i32;
   read = &x as *const i32;

   // cuando llegamos aquí `x` ya no es válida
}

unsafe {
   *write += 1;
}

println!("{}", unsafe {
   *read
});

Cuando primero escribí este código yo esperaba ver algo como un segmentation fault o aunque sea un panic de algún tipo. La cruda realidad es que el código funciona sin chistar y muestra el resultado esperado, pese a que en efecto x ya no es válida en el código. Al final de esta entrada encontrarás la explicación de por qué es así. Puedes aprovechar mientras tanto en pensar por qué el código funciona y seguir leyendo 🙂.

El heap

En los ejemplos anteriores, y dado que los enteros son tipos de datos básicos la información se guarda en el stack. Y para entender mejor los errores posibles al usar apuntadores en bruto decidí también meterme con el heap, que es el otro lugar donde puede estar guardada la información de mis variables (ok, hay más, pero no nos vamos a meter con eso).

En el siguiente código utilizo una variable guardada en el heap y veo qué le pasa cuando uso apuntadores a ella después de haberla liberado.

// En rust esta es la forma más común de mover un valor al heap
let mut x = Box::new(5);

// El tipo Box tiene su propia manera de darme un apuntador en bruto.
// La variable `x` deja de ser válida desde aquí.
let write: *mut i32 = Box::into_raw(x);

// simplemente uso el apuntador mutable para obtener una inmutable,
// en realidad también pude haber hecho esto en los programas anteriores
let read = write as *const i32;

{
   // en este scope artificial recupero la memoria de `x` en una nueva
   // variable para aprovechar la liberación automática de la memoria
   // que me provee Box
   let y = unsafe {
      Box::from_raw(write)
   };

   // cuando `y` llega aquí deja de ser válida
}

unsafe {
  *write += 1;
}

println!("{}", unsafe {
   *read
});

Wowowowow! ¿Qué fue eso? El resultado de la ejecución de este código es 1 en vez de 6 como había estado sucediendo hasta ahora. Esto quiere decir que ahora sí rompí algunas reglas de forma fundamental y que cuando la memoria de la dirección de x es liberada en su lugar quedan ceros, lo cual explica que al interpretar esa dirección como entero y aumentarlo en 1 el resultado sea 1. Justamente este es el tipo de errores de que nos quiere prevenir el compilador.

Como el objetivo de este post es romper las cosas de forma fundamental iré un poco más lejos en mi afán de causar un panic o un comportamiento aberrante.

// Todo aquí es igual que antes
let mut x = Box::new(5);
let write: *mut i32 = Box::into_raw(x);
let read = write as *const i32;

{
   let y = unsafe {
      Box::from_raw(write)
   };
}

// excepto que esta vez después de liberar la memoria de `x` a través de `y`
// crearé esta variable `z` inmutable con el valor 20.
let z = Box::new(20);

unsafe {
  *write += 1;
}

// y lo que voy a imprimir es el valor de `z`.
// Si es inmutable ¿qué puede salir mal?
println!("{}", z);

¡Pues claro! Obtenemos el tan deseado (y aberrante) 21 que he estado buscando. Sucede que la memoria de z es reservada donde estaba antes la memoria de x, y como yo conservé un apuntador a esa memoria y aunque z es inmutable yo estoy incumpliendo las reglas y modificando su valor.

Imagínate si en lugar de un entero estuviera almacenando un struct, o un arreglo con información de la tarjeta de crédito a la que se está haciendo una transferencia... Esta es la clase de comportamientos por las cuales Rust es un lenguaje sumamente relevante.

¿Y qué pasó en el ejemplo del stack?

En el caso del heap pude tomar ventaja del hecho de que al liberarse el espacio de una variable es muy probable (o seguro) que la siguiente tome su lugar para evitar así pedir más memoria al sistema operativo. Sin embargo el stack funciona de forma diferente.

En el stack (pila en español) viven las variables locales de una función, sus argumentos y su valor de retorno en algún orden que desconozco. Cada que una función es llamada un nuevo espacio (stack frame) es reservado, cuando la función termina ese espacio es liberado.

Considera el siguiente código:

fn log() {
}

fn foo() {
   var();
}

fn main() {
   foo();
   log();
}

El stack se vería algo así (dramatización imprecisa, cada cuadro contiene los argumentos, valor de retorno y variables locales de cada función):

Al principio de main:
+------+
| main |
+------+

Al entrar a foo:
+------+-----+
| main | foo |
+------+-----+

Al entrar a var:
+------+-----+-----+
| main | foo | var |
+------+-----+-----+

Al terminar var:
+------+-----+
| main | foo |
+------+-----+

Al terminar foo:
+------+
| main |
+------+

Al entrar a log:
+------+-----+
| main | log |
+------+-----+

Al salir de log:
+------+
| main |
+------+

Dado que las variables dentro de una función suelen ser relativamente pequeñas es posible simplemente reservar memoria para todas ellas en un principio y no reciclar aunque en el código parezca que la memoria de una ya no es necesaria. Es por esto que en el ejemplo el código arroja el valor esperado. Aunque sintácticamente el valor de x ya no es válido, su lugar de memoria no está siendo ocupado por ningún otro valor así que sigue teniendo el 5 que habíamos dejado ahí:

let ptr;

{
   let mut x = 5;
   ptr = &mut x as *mut i32;
}

unsafe {
   *ptr += 1;
}

println!("{}", unsafe {
   *ptr
});

Sin embargo, usando este conocimiento del stack es posible construir un ejemplo donde las cosas sí se rompen, y se rompen feo. Considera el siguiente código:

// esta función devuelve un apuntador inválido por definición pues
// el valor referenciado es eliminado al terminar la función.
// Este código no compilaría con referencias normales.
fn foo() -> *mut u32 {
    let mut x: u32 = 5;

    &mut x as *mut u32
   // `x` deja de ser válida aquí
}

// esta solo es una función cualquiera
fn var(a: i32, b: i32) -> i32 {
    a + b
}

fn main() {
   // obtenemos el apuntador inválido aquí
   let ptr = foo();

   // utilizo la función cualquiera para llenar la memoria referenciada
   // por `ptr` con alguna otra información
   var(-10, -20);

   // modificamos el apuntador inválido. Solo Coatlicue sabe qué habrá ahí
   unsafe {
      *ptr += 5;
   }

   println!("{}", unsafe {
      *ptr
   });
}

¿Qué valor se va a imprimir ahí? Depende completamente de la organización interna de los stack frames, pero definitivamente no es el 10 que uno esperaría. En este ejemplo utilicé enteros sin signo en una de las funciones y con signo en otra, con la única finalidad de hacer más dramático el resultado.

En efecto, lo que está pasando es que el stack frame de la función var (que además es ligeramente más grande) ocupa el espacio de memoria donde estaba foo y sus variables, entre ellas x. Mi sospecha es que en particular el lugar donde estaba x lo está ocupando el valor de retorno.

Si tienes curiosidad de qué imprimió y demasiada flojera para ejecutar el código fue esto:

4294967271