En primer lugar, voy a ayudarte a entender de forma sencilla qué es un ataque de reentrada y cómo puedes prevenirlo, y luego, profundizaré en ejemplos de código para mostrar dónde están las vulnerabilidades, cuál sería el código del atacante. y lo más importante, te mostraré los últimos métodos verificados para proteger no solo uno sino todos los contratos inteligentes de tu proyecto.

Spoiler: si ya has oído hablar del modificador nonReentrant(), sigue leyendo porque estás a punto de descubrir algunas líneas debajo del modificador globalNonReentrant() y el patrón checks-efects-interactions.



En la imagen de arriba tenemos ContratoA y ContratoB. Ahora, como sabe, un contrato inteligente puede interactuar con otro contrato inteligente, como en este caso, el ContratoA puede llamar al ContratoB. Entonces, la idea básica de reentrada es que el ContratoB puede volver a llamar al ContratoA mientras el ContratoA aún se está ejecutando.

Entonces, ¿cómo puede utilizar esto el atacante?

Arriba tenemos el ContratoA que tiene 10 Ethers y vemos que el ContratoB ha almacenado 1 Ether en el ContratoA. En este caso, el ContratoB podrá utilizar la función de retiro del ContratoA y enviarse Ether de regreso a sí mismo cuando pase el cheque donde su saldo es mayor que 0, para luego modificar su saldo total a 0.



Veamos ahora cómo puede ContractB utilizar la reentrada para explotar la función de retirada y robar todos los Ethers de ContractA. Básicamente, el atacante necesitará dos funciones: ataque() y respaldo().

En Solidity, una función alternativa es una función externa sin nombre, parámetros ni valores de retorno. Cualquiera puede llamar a una función alternativa de la siguiente manera: Llamando a una función que no existe dentro del contrato; Llamar a una función sin pasar los datos requeridos; Envío de Ether sin ningún dato al contrato.

La forma en que funciona la reentrada (sigamos las flechas paso a paso) es cuando el atacante llama a la función ataque() que en su interior llama a la función retirada() desde el ContratoA. Dentro de la función verificará si el saldo del ContratoB es mayor que 0 y de ser así continuará la ejecución.



Dado que el saldo de ContractB es mayor que 0, devuelve ese 1 Ether y activa la función de respaldo. Observe que en este momento el ContratoA tiene 9 Ethers y el ContratoB ya tiene 1 Ether.



A continuación, cuando se ejecuta la función alternativa, se activa nuevamente la función de retiro del ContratoA, verificando nuevamente si el saldo del ContratoB es mayor que 0. Si revisa nuevamente la imagen de arriba, notará que su saldo sigue siendo 1 Ether.



Eso significa que la verificación pasa y envía otro Ether al ContratoB, lo que activa la función alternativa. Tenga en cuenta que, dado que la línea donde tenemos “saldo=0” nunca se ejecuta, esto continuará hasta que se acabe todo el Ether del ContratoA.

___________

Ahora echemos un vistazo a un contrato inteligente donde podemos identificar la reentrada con el código Solidity.



En el contrato EtherStore, tenemos la función deposit() que almacena y actualiza los saldos del remitente y luego la función withdrawAll() que tomará todos los saldos almacenados a la vez. Por favor, observe la implementación de withdrawAll() donde verifica primero con el require que el saldo sea mayor que 0 y justo después envía el Ether, nuevamente, dejando para el final la actualización del saldo del remitente a 0.



Aquí tenemos el contrato Attack que utilizará la reentrada para drenar el contrato EtherStore. Analicemos su código:

  • En su constructor, el atacante pasará la dirección EtherStore para crear una instancia y así poder utilizar sus funciones.

  • Allí vemos la función fallback() que se llamará cuando EtherStore envíe Ether a este contrato. Dentro llamará a retirar de EtherStore siempre que el saldo sea igual o mayor a 1.

  • Y dentro de la función attack() tenemos la lógica que explotará EtherStore. Como podemos ver, primero iniciaremos el ataque asegurándonos de tener suficiente ether, luego depositaremos 1 ether para tener un saldo mayor a 0 en EtherStore y así pasar las comprobaciones antes de comenzar a retirar.

Expliqué anteriormente en el ejemplo de ContractA y ContractB paso a paso cómo se ejecutará el código, así que ahora hagamos un resumen de cómo será. En primer lugar, el atacante llamará a attack(), que dentro llamará a retiroAll() desde EtherStore, que luego enviará Ether a la función alternativa del contrato de ataque. Y allí comenzará la reentrada y agotará el saldo de EtherStore.

Entonces, ¿cómo podemos proteger nuestros contratos de ataques de reentrada?

Te voy a mostrar tres técnicas de prevención para protegerlos por completo. Voy a cubrir cómo evitar la reentrada en una sola función, la reentrada entre funciones y el reentrada entre contratos.



La primera técnica para proteger una sola función es usar un modificador llamado noReentrant.

Un modificador es un tipo especial de función que se utiliza para modificar el comportamiento de otras funciones. Los modificadores le permiten agregar condiciones o funcionalidades adicionales a una función sin tener que reescribir toda la función.

Lo que hacemos aquí es bloquear el contrato mientras se ejecuta la función. De esta manera, no podrá volver a ingresar a la función única, ya que deberá revisar el código de la función y luego cambiar la variable de estado bloqueado a falso para pasar nuevamente la verificación realizada en el requisito.

___________



La segunda técnica es hacer uso del patrón Verificaciones-Efectos-Interacciones, que protegerá nuestros contratos de la reentrada entre funciones. ¿Puedes ver en el contrato actualizado de EtherStore lo que ha cambiado?

Para profundizar en el patrón Check-Effects-Interaction, recomiendo leer https://fravoll.github.io/solidity-patterns/checks_effects_interactions.html





Arriba vemos la comparación entre el código vulnerable de la imagen de la izquierda donde se actualizó el saldo después de enviar el Ether, que como se ve arriba potencialmente nunca podría alcanzarse, y a la derecha lo que se ha hecho es mover los saldos[ msg.sender] = 0 (o efecto) justo después de require(bal > 0) (verificación) pero antes de enviar ether (interacción).

De esta manera nos aseguraremos de que incluso si otra función accede a retiroAll(), este contrato estará protegido del atacante porque el saldo siempre se actualizará antes de enviar el Ether.

Patrón creado por https://twitter.com/GMX_IO

La tercera técnica que les voy a mostrar es crear el contrato GlobalReentrancyGuard para protegerse contra la reentrada entre contratos. Es importante entender que esto es aplicable a proyectos con múltiples contratos que interactúan entre sí.

La idea aquí es la misma que en el modificador noReentrant que he explicado en la primera técnica, ingresa el modificador, actualiza una variable para bloquear el contrato y no lo desbloquea hasta que no termina el código. La gran diferencia aquí es que estamos usando una variable almacenada en un contrato separado que se usa como lugar para verificar si la función se ingresó o no.







He creado aquí un ejemplo sin código real y solo con nombres de funciones como referencia para comprender la idea ya que, según mi experiencia, puede ayudar a visualizar la situación en lugar de simplemente escribirla con palabras.

Aquí, el atacante estaría llamando a la función en el contrato ScheduledTransfer que, después de cumplir las condiciones, enviaría el Ether especificado al contrato AttackTransfer que, por lo tanto, ingresaría a la función alternativa y por lo tanto "cancelaría" la transacción desde el punto de origen del contrato ScheduledTransfer. ver y aun así recibir el Éter. Y de esta manera estaría iniciando una búsqueda hasta drenar todos los Ethers de ScheduledTransfer.

Bueno, usar GlobalReentrancyGuard que mencioné anteriormente evitará tal escenario de ataque.

__________________

Twitter @TheBlockChainer para encontrar más actualizaciones diarias sobre contratos inteligentes, seguridad Web3, solidez, auditoría de contratos inteligentes y más.

__________________