Ir al contenido principal
Change page

Seguridad de los contratos inteligentes

Última edición: @MGETH(opens in a new tab), 22 de mayo de 2024

Los contratos inteligentes son extremadamente flexibles y capaces de controlar grandes cantidades de valor y datos, además de ejecutar lógica inmutable con base en el código implementado en la cadena de bloques. Esto ha creado un vibrante ecosistema de aplicaciones que no necesitan confianza (trustless) y descentralizadas que ofrecen muchas ventajas sobre los sistemas antiguos. También representan oportunidades para los atacantes que buscan obtener beneficios explotando las vulnerabilidades de los contratos inteligentes.

Las cadenas de bloques públicas como Ethereum complican aún más la cuestión de la seguridad de los contratos inteligentes. El código de los contratos ya implementado por lo general no puede cambiarse para corregir fallas de seguridad, mientras que los activos robados de los contratos inteligentes son extremadamente difíciles de rastrear y en su mayor parte irrecuperables debido a la inmutabilidad.

Aunque las cifras varían, se estima que la cantidad total de valor robado o perdido debido a defectos de seguridad en los contratos inteligentes supera fácilmente los USD 1000 millones de dólares. Esto incluye incidentes de alto perfil, tal como el hackeo a la DAO(opens in a new tab) (3,6 millones de ETH robados, por valor de más de USD 1000 millones a precios actuales), el hackeo de la billetera multifirma de Parity(opens in a new tab) (USD 30M perdidos a manos de hackers) y el problema de billeteras congeladas de Parity(opens in a new tab) (mas de USD 300M en ETH bloqueados para siempre).

Los problemas mencionados anteriormente hacen que sea imperativo que los desarrolladores inviertan esfuerzos en la creación de contratos inteligentes seguros, robustos y resistentes. La seguridad de los contratos inteligentes es un asunto serio que todo desarrollador hará bien en aprender. Esta guía abordará consideraciones de seguridad para los desarrolladores de Ethereum y explorará recursos para mejorar la seguridad de los contratos inteligentes.

Requisitos previos

Asegúrese de estar familiarizado con los principios fundamentales del desarrollo de contratos inteligentes antes de abordar la seguridad.

Pautas para crear contratos inteligentes seguros en Ethereum

1. Diseñar controles de acceso apropiados

En los contratos inteligentes, las funciones definidas como public o external pueden ser invocadas por cualquier cuenta de propiedad externa (EOA) o cuentas asociadas a otros contratos. Especificar la visibilidad pública de las funciones es necesario si quiere que otros interactúen con su contrato. Por su parte, las funciones marcadas como private solo pueden ser invocadas por funciones dentro del contrato inteligente y no por cuentas externas. Dar a todos los participantes de la red acceso a las funciones del contrato puede causar problemas, especialmente si esto significa que cualquiera pueda realizar operaciones sensibles (por ejemplo, mintear nuevos tokens).

Para evitar el uso no autorizado de las funciones de los contratos inteligentes, es necesario implementar controles de acceso seguros. Los mecanismos de control de acceso restringen la capacidad de utilizar ciertas funciones de un contrato inteligente a las entidades aprobadas, como las cuentas responsables de la gestión del contrato. El patrón Ownable y role-based control son dos patrones útiles para implementar el control de acceso en los contratos inteligentes:

Patrón Ownable

En el patrón Ownable, se establece una dirección como "propietaria" del contrato durante el proceso de creación del contrato. A las funciones protegidas se les asigna un modificador OnlyOwner, que asegura que el contrato autentique la identidad de la dirección invocante antes de ejecutar la función. Las llamadas o invocaciones a funciones protegidas desde otras direcciones distintas a la del propietario del contrato siempre se revierten, lo que impide el acceso no deseado.

Control de acceso basado en roles

Registrar una única dirección como Owner en un contrato inteligente introduce el riesgo de centralización y representa un punto único de falla. Si las claves de la cuenta del propietario se ven comprometidas, los atacantes pueden atacar el contrato. Es por ello que utilizar un patrón de control de acceso basado en roles con múltiples cuentas administrativas puede ser una mejor opción.

En el control de acceso basado en roles, el acceso a funciones sensibles se distribuye entre un conjunto de participantes de confianza. Por ejemplo, una cuenta puede encargarse de mintear tokens, mientras que otra cuenta realiza actualizaciones o pausa el contrato. Al descentralizar el control de acceso de esta manera, se eliminan los puntos únicos de falla y se reducen los supuestos de confianza para los usuarios.

Uso de billeteras multifirma

Otro enfoque para implementar un control de acceso seguro es utilizar una cuenta multifirma para gestionar un contrato. A diferencia de una EOA normal, las cuentas multifirma son propiedad de varias entidades y requieren las firmas de un número mínimo de cuentas, por ejemplo, de 3 a 5, para ejecutar las transacciones.

El uso de una cuenta multifirma para el control de acceso introduce una capa adicional de seguridad, ya que las acciones en el contrato de destino requieren el consentimiento de varias partes. Esto es particularmente útil si es necesario utilizar el patrón Ownable, ya que hace más difícil que un atacante o un insider deshonesto manipule las funciones sensibles del contrato con fines maliciosos.

2. Uso de declaraciones require(), assert() y revert() para proteger las operaciones de un contrato

Como se ha mencionado, cualquiera puede invocar funciones públicas de su contrato inteligente una vez que se implementa en la cadena de bloques. Dado que no se puede saber de antemano cómo van a interactuar las cuentas externas con un contrato, lo ideal es implementar protecciones internas contra las operaciones problemáticas antes de la implementación. Se puede imponer un comportamiento correcto en los contratos inteligentes utilizando las declaraciones o sentencias require(), assert() y revert() para activar excepciones y revertir los cambios de estado si la ejecución no satisface ciertos requisitos.

require(): require se define al inicio de las funciones y garantiza que se cumplan las condiciones predefinidas antes de que se ejecute la función invocada. Una declaración require puede utilizarse para validar las entradas del usuario, comprobar las variables de estado o autenticar la identidad de la cuenta invocante antes de progresar con una función.

assert(): assert() se utiliza para detectar errores internos y comprobar las violaciones de "invariantes" en su código. Una invariante es una afirmación lógica sobre el estado de un contrato que debe ser cierta para todas las ejecuciones de la función. Un ejemplo de invariante es el total suministro máximo o el saldo de un contrato de tokens. El uso de assert() asegura que su contrato nunca alcance un estado vulnerable, y si lo hace, todos los cambios en las variables de estado se revierten.

revert(): revert() puede utilizarse en una declaración if-else que desencadene una excepción si no se cumple la condición requerida. El contrato de ejemplo que se muestra a continuación utiliza revert() para proteger la ejecución de las funciones:

1pragma solidity ^0.8.4;
2
3contract VendingMachine {
4 address owner;
5 error Unauthorized();
6 function buy(uint amount) public payable {
7 if (amount > msg.value / 2 ether)
8 revert("Not enough Ether provided.");
9 // Perform the purchase.
10 }
11 function withdraw() public {
12 if (msg.sender != owner)
13 revert Unauthorized();
14
15 payable(msg.sender).transfer(address(this).balance);
16 }
17}
Mostrar todo

3. Probar contratos inteligentes y verificar que el código sea correcto

La inmutabilidad del código que se ejecuta en la Máquina virtual de Ethereum implica que los contratos inteligentes exijan un mayor nivel de evaluación de la calidad durante la fase de desarrollo. Probar el contrato de forma exhaustiva y observar cualquier resultado inesperado mejorará mucho la seguridad y protegerá a los usuarios a largo plazo.

El método habitual es escribir pruebas de unidades pequeñas utilizando datos simulados que se espera que el contrato reciba de los usuarios. Hacer pruebas unitarias es bueno para probar la funcionalidad de ciertas funciones y asegurar que un contrato inteligente funcione como se espera.

Desafortunadamente, las pruebas unitarias son poco efectivas para mejorar la seguridad de los contratos inteligentes cuando se utilizan de forma aislada. Una prueba unitaria puede demostrar que una función se ejecuta correctamente para datos simulados, pero son tan eficaces como las pruebas que se escriben. Esto dificulta la detección de casos límite y vulnerabilidades que podrían romper la seguridad de su contrato inteligente.

Una mejor estrategia es combinar las pruebas unitarias con pruebas basadas en propiedades realizadas mediante análisis estáticos y dinámicos. El análisis estático se basa en representaciones de bajo nivel, como gráficos de flujo de control(opens in a new tab) y árboles sintácticos abstractos(opens in a new tab) para analizar los estados alcanzables de un programa y las rutas de ejecución. Mientras tanto, técnicas de análisis dinámicos, como las auditorías de seguridad (fuzzing) de contratos inteligentes(opens in a new tab), ejecutan un código de contrato con valores de introducción aleatorios que infringen las propiedades de seguridad.

La verificación formal es otra técnica para verificar las propiedades de seguridad en los contratos inteligentes. A diferencia de las pruebas habituales, la verificación formal puede demostrar de forma concluyente la ausencia de errores en un contrato inteligente. Esto se consigue creando una especificación formal que capture las propiedades de seguridad deseadas y demostrando que un modelo formal de los contratos se adhiera a esta especificación.

4. Solicitar una revisión independiente de su código

Después de probar su contrato, es bueno pedir a otros que comprueben el código fuente para detectar cualquier problema de seguridad. Las pruebas no descubrirán todas las fallas de un contrato inteligente, pero conseguir una revisión independiente aumenta la posibilidad de detectar vulnerabilidades.

Auditorías

Encargar una auditoría de un contrato inteligente es una forma de realizar una revisión independiente del código. Los auditores desempeñan un papel importante a la hora de garantizar que los contratos inteligentes sean seguros y estén libres de defectos de calidad y errores de diseño.

Dicho esto, hay que evitar tratar las auditorías como una bala de plata. Las auditorías no detectarán todos los errores y están diseñadas principalmente para proporcionar una ronda adicional de revisiones, que puede ayudar a detectar los problemas que los desarrolladores pasaron por alto durante el desarrollo y las pruebas iniciales. También es necesario cumplir con buenas prácticas para trabajar con los auditores(opens in a new tab), como documentar el código adecuadamente y añadir comentarios en línea, para maximizar el beneficio de una auditoría del contrato inteligente.

Dar recompensas por detección de errores

La creación de un programa de recompensas por errores es otro enfoque para implementar revisiones de código externas. Un bug bounty es una recompensa económica que se da a las personas (normalmente hackers de sombrero blanco) que descubren vulnerabilidades en una aplicación.

Cuando se utilizan correctamente, las recompensas por errores ofrecen a los miembros de la comunidad de hackers un incentivo para inspeccionar su código en busca de fallas críticas. Un ejemplo de la vida real es el "bug del dinero infinito" que habría permitido a un atacante crear una cantidad ilimitada de Ether en Optimism(opens in a new tab), un protocolo de Capa 2 que se ejecuta en Ethereum. Afortunadamente, un hacker de sombrero blanco descubrió la falla(opens in a new tab) y notificó al equipo, obteniendo un premio grande en el proceso(opens in a new tab).

Una estrategia útil es establecer el pago de un programa de recompensas por fallas en proporción a la cantidad de fondos en juego. Descrito como el "scaling bug bounty(opens in a new tab)", este enfoque proporciona incentivos financieros para que los individuos revelen responsablemente las vulnerabilidades en lugar de explotarlas.

5. Seguir las mejores prácticas durante el desarrollo del contrato inteligente

La existencia de auditorías y recompensas por errores no lo exime de la responsabilidad de escribir código de alta calidad. La sólida seguridad en los contratos inteligentes empieza por seguir procesos de diseño y desarrollo adecuados:

6. Implementar planes sólidos de recuperación de desastres

El diseño de controles de acceso seguros, la implementación de modificadores de funciones y otras sugerencias pueden mejorar la seguridad de los contratos inteligentes, pero no pueden descartar la posibilidad de explotaciones maliciosas. Crear contratos inteligentes seguros requiere “prepararse para el fracaso” y tener un plan de respaldo para responder eficazmente a los ataques. Un plan adecuado de recuperación de desastres incorporará algunos o todos los siguientes componentes:

Actualizaciones del contrato

Si bien los contratos inteligentes de Ethereum son inmutables de forma predeterminada, es posible lograr cierto grado de mutabilidad usando patrones de actualización. La actualización de contratos es necesaria en los casos en que una falla crítica haga inutilizable su viejo contrato e implementar nueva lógica sea la opción más viable.

Los mecanismos de actualización de contratos funcionan de forma diferente, pero el “patrón de proxy” es una de las formas más populares. Los patrones de proxy(opens in a new tab) dividen el estado de una aplicación y la lógica entre dos contratos. El primer contrato (llamado “contrato proxy”) almacena variables de estado (p. ej., los balances de los usuarios), mientras que el segundo contrato (llamado “contrato de lógica”) contiene el código para la ejecución de las funciones del contrato.

Las cuentas interactúan con el contrato proxy, que envía todas las llamadas de función al contrato de lógica usando la llamada de bajo nivel delegatecall()(opens in a new tab). A diferencia de una llamada de mensaje normal, delegatecall() asegura que el código que se ejecuta en la dirección del contrato de lógica sea ejecutado en el contexto del contrato de llamada. Esto significa que el contrato de lógica siempre escribirá en el almacenamiento del proxy (en lugar de su propio almacenamiento) y que los valores originales de msg. ender y msg.value serán preservados.

Delegar llamadas al contrato de lógica requiere almacenar su dirección en el almacenamiento del contrato proxy. Por lo tanto, actualizar la lógica del contrato es solo cuestión de implementar otro contrato de lógica y almacenar la nueva dirección en el contrato proxy. A medida que las llamadas subsiguientes al contrato proxy se enruten automáticamente al nuevo contrato de lógica, habrá “actualizado” el contrato sin modificar realmente el código.

Más información sobre la actualización de contratos.

Paradas de emergencia

Como se ha mencionado, las auditorías y pruebas exhaustivas no pueden descubrir todos los errores en un contrato inteligente. Si aparece una vulnerabilidad en su código después de la implementación, corregirla es imposible, ya que no puede cambiar el código que se ejecuta en la dirección del contrato. Además, los mecanismos de actualización (p. ej. los patrones proxy) pueden tardar en implementarse (suelen requerir la aprobación de diferentes partes), lo que solo da a los atacantes más tiempo para causar más daño.

La opción nuclear es implementar una función de “parada de emergencia” que bloquee las llamadas a funciones vulnerables en un contrato. Las paradas de emergencia típicamente constan de los siguientes componentes:

  1. Una variable booleana global que indica si el contrato inteligente está en un estado de detenimiento o no. Esta variable se establece en false al configurar el contrato, pero volverá a true una vez que se detenga el contrato.

  2. Funciones que hagan referencia a la variable booleana en su ejecución. Tales funciones son accesibles cuando el contrato inteligente no se detiene, y se vuelven inaccesibles cuando se activa la función de parada de emergencia.

  3. Una entidad que tiene acceso a la función de parada de emergencia, que establece la variable booleana en true. Para evitar acciones maliciosas, las llamadas a esta función se pueden restringir a una dirección de confianza (por ejemplo, el propietario del contrato).

Una vez que el contrato active la parada de emergencia, no se podrán invocar ciertas funciones. Esto se logra envolviendo funciones selectas en un modificador que haga referencia a la variable global. A continuación se muestra un ejemplo(opens in a new tab) que describe una implementación de este patrón en los contratos:

1// This code has not been professionally audited and makes no promises about safety or correctness. Use at your own risk.
2
3contract EmergencyStop {
4
5 bool isStopped = false;
6
7 modifier stoppedInEmergency {
8 require(!isStopped);
9 _;
10 }
11
12 modifier onlyWhenStopped {
13 require(isStopped);
14 _;
15 }
16
17 modifier onlyAuthorized {
18 // Check for authorization of msg.sender here
19 _;
20 }
21
22 function stopContract() public onlyAuthorized {
23 isStopped = true;
24 }
25
26 function resumeContract() public onlyAuthorized {
27 isStopped = false;
28 }
29
30 function deposit() public payable stoppedInEmergency {
31 // Deposit logic happening here
32 }
33
34 function emergencyWithdraw() public onlyWhenStopped {
35 // Emergency withdraw happening here
36 }
37}
Mostrar todo
Copiar

Este ejemplo muestra las características básicas de las paradas de emergencia:

  • isStopped es un booleano que evalúa como false al principio y true cuando el contrato entra en modo de emergencia.

  • Los modificadores de funciones onlyWhenStopped y stoppedInEmergency comprueban la variable isStopped. stoppedInEmergency se utiliza para controlar las funciones que deberían ser inaccesibles cuando el contrato es vulnerable (por ejemplo, deposit()). Las llamadas a estas funciones simplemente se revertirán.

onlyWhenStopped se utiliza para funciones que deben ser invocables durante una emergencia (por ejemplo, emergencyWithdraw()). Tales funciones pueden ayudar a resolver la situación, de ahí su exclusión de la lista de "funciones restringidas".

El uso de una funcionalidad de parada de emergencia proporciona un recurso efectivo para hacer frente a vulnerabilidades graves en su contrato inteligente. No obstante, aumenta la necesidad de que los usuarios confíen en que los desarrolladores no lo activen por razones de su interés. Con este fin, la descentralización del control de la parada de emergencia, ya sea sometiéndola a un mecanismo de votación en cadena, un bloqueo de tiempo o la aprobación de una billetera multifirma, son posibles soluciones.

Monitoreo de eventos

Los eventos(opens in a new tab) le permiten realizar un seguimiento de las llamadas a las funciones de un contrato inteligente y supervisar los cambios en las variables de estado. Es ideal programar su contrato inteligente para que emita un evento cada vez que alguna parte realice una acción crítica para la seguridad (por ejemplo, retirar fondos).

El registro de eventos y su supervisión fuera de la cadena proporciona información sobre las operaciones del contrato y ayuda a un descubrimiento más rápido de acciones maliciosas. Esto significa que su equipo puede responder más rápido a los hackeos y tomar medidas para mitigar el impacto en los usuarios, como pausar funciones o realizar una actualización.

También puede optar por una herramienta de monitoreo lista para usar que reenvíe automáticamente alertas cada vez que alguien interactúe con sus contratos. Estas herramientas le permitirán crear alertas personalizadas basadas en diferentes activadores, como el volúmen de transacciones, la frecuencia de las llamadas a funciones o las funciones específicas involucradas. Por ejemplo, podría programar una alerta que llegue cuando la cantidad retirada en una misma transacción supere un umbral en particular.

7. Diseñar sistemas de gobernanza seguros

Es posible que desee descentralizar su aplicación entregando el control de los contratos inteligentes básicos a los miembros de la comunidad. En este caso, el sistema de contratos inteligentes incluirá un módulo de gobernanza, es decir, un mecanismo que permita a los miembros de la comunidad aprobar acciones administrativas a través de un sistema de gobernanza en cadena. Por ejemplo, los titulares de tokens pueden votar por una propuesta para actualizar un contrato proxy a una nueva implementación.

La gobernanza descentralizada puede ser beneficiosa, especialmente porque alinea los intereses de los desarrolladores y los usuarios finales. A pesar de todo, los mecanismos de gobernanza de contratos inteligentes pueden introducir nuevos riesgos si se implementan incorrectamente. Un escenario plausible es si un atacante adquiere un enorme poder de voto (medido por el número de tokens mantenidos) mediante la obtención de un préstamo flash y obliga a aceptar una propuesta maliciosa.

Una manera de prevenir problemas relacionados con la gobernanza en cadena es usar un bloqueo de tiempo o timelock(opens in a new tab). Un bloqueo de tiempo impide que un contrato inteligente ejecute ciertas acciones hasta que pase una cantidad específica de tiempo. Otras estrategias incluyen asignar un "peso de votación" a cada token en función de cuánto tiempo ha estado bloqueado, o medir el poder de voto de una dirección en un período histórico (por ejemplo, 2-3 bloques en el pasado) en lugar del bloque actual. Ambos métodos reducen la posibilidad de acumular rápidamente el poder de voto para cambiar los votos en cadena.

Consulte más información sobre el diseño de sistemas de gobernanza seguros(opens in a new tab) y los diferentes mecanismos de votación en las DAO(opens in a new tab).

8. Reducir la complejidad del código al mínimo

Los desarrolladores de software tradicionales están familiarizados con el principio KISS ("mantenlo simple, estúpido") (Keep it simple stupid), que desaconseja introducir complejidad innecesaria en el diseño de software. Esto sigue la idea de pensamiento de hace tiempo de que "los sistemas complejos fallan de maneras complejas" y son más susceptibles a errores costosos.

Mantener las cosas simples es de particular importancia a la hora de escribir contratos inteligentes, dado que los contratos inteligentes están controlando potencialmente grandes cantidades de valor. Un consejo para lograr simplicidad al escribir contratos inteligentes es reutilizar bibliotecas existentes, como OpenZeppelin Contracts(opens in a new tab), siempre que sea posible. Debido a que estas bibliotecas han sido ampliamente auditadas y probadas por los desarrolladores, su uso reduce las posibilidades de introducir errores al escribir nuevas funcionalidades desde cero.

Otro consejo común es escribir pequeñas funciones y mantener los contratos modulares dividiendo la lógica empresarial entre múltiples contratos. Escribir código más simple no solo reduce la superficie de ataque en un contrato inteligente, sino que también hace que sea más fácil razonar sobre la corrección del sistema general y detectar posibles errores de diseño temprano.

9. Defenderse de las vulnerabilidades comunes de los contratos inteligentes

Reentrada

La EVM no permite la concurrencia, lo que significa que dos contratos involucrados en una llamada de mensaje no pueden ejecutarse simultáneamente. Una llamada externa pausa la ejecución y la memoria del contrato de llamada hasta que la llamada regresa, momento en el que la ejecución procede normalmente. Este proceso se puede describir formalmente como la transferencia de flujo de control(opens in a new tab) a otro contrato.

Aunque en su mayor parte resulta inofensivo, la transferencia del flujo de control a contratos sin confianza puede causar problemas, como el reingreso o reentrada. Un ataque de reentrada ocurre cuando un contrato malicioso vuelve a llamar a un contrato vulnerable antes de que se complete la invocación de la función original. Este tipo de ataque se explica mejor con un ejemplo.

Considere un simple contrato inteligente ("Víctima") que permita a cualquier persona depositar y retirar Ether:

1// This contract is vulnerable. Do not use in production
2
3contract Victim {
4 mapping (address => uint256) public balances;
5
6 function deposit() external payable {
7 balances[msg.sender] += msg.value;
8 }
9
10 function withdraw() external {
11 uint256 amount = balances[msg.sender];
12 (bool success, ) = msg.sender.call.value(amount)("");
13 require(success);
14 balances[msg.sender] = 0;
15 }
16}
Mostrar todo
Copiar

Este contrato expone una función withdraw() para permitir a los usuarios retirar ETH previamente depositados en el contrato. Al procesar un retiro, el contrato realiza las siguientes operaciones:

  1. Comprueba el saldo de ETH del usuario
  2. Envía fondos a la dirección de llamada
  3. Restablece su saldo a 0, evitando retiros adicionales del usuario

La función withdraw() en el contrato Victim sigue un patrón de "comprobaciones-interacciones-efectos". comprueba si se cumplen las condiciones necesarias para la ejecución (es decir, el usuario tiene un saldo de ETH positivo) y realiza la interacción enviando ETH a la dirección de la persona que llama, antes de aplicar los efectos de la transacción (es decir, reduciendo el saldo del usuario).

Si se invoca withdraw() desde una cuenta de propiedad externa (EOA), la función se ejecuta como se espera: msg.sender.call.value() envía ETH al invocante. Sin embargo, si msg.sender es una cuenta de contrato inteligente que llama a withdraw(), el envío de fondos usando msg.sender.call.value() también activará el código almacenado en esa dirección para que se ejecute.

Imagine que este es el código implementado en la dirección del contrato:

1 contract Attacker {
2 function beginAttack() external payable {
3 Victim(victim_address).deposit.value(1 ether)();
4 Victim(victim_address).withdraw();
5 }
6
7 function() external payable {
8 if (gasleft() > 40000) {
9 Victim(victim_address).withdraw();
10 }
11 }
12}
Mostrar todo
Copiar

Este contrato está diseñado para hacer tres cosas:

  1. Aceptar un depósito de otra cuenta (probablemente la EOA del atacante)
  2. Depositar 1 ETH en el contrato de la víctima
  3. Retirar el 1 ETH almacenado en el contrato inteligente

No hay nada malo aquí, excepto que Attacker tiene otra función que llama a withdraw() en Victim de nuevo si el gas que queda del entrante msg.sender.call.value es más de 40.000. Esto le da al Attacker la capacidad de volver a ingresar a Victim y retirar más fondos antes de que se complete la primera invocación de withdraw. El ciclo se ve así:

1- Attacker's EOA calls `Attacker.beginAttack()` with 1 ETH
2- `Attacker.beginAttack()` deposits 1 ETH into `Victim`
3- `Attacker` calls `withdraw() in `Victim`
4- `Victim` checks `Attacker`’s balance (1 ETH)
5- `Victim` sends 1 ETH to `Attacker` (which triggers the default function)
6- `Attacker` calls `Victim.withdraw()` again (note that `Victim` hasn’t reduced `Attacker`’s balance from the first withdrawal)
7- `Victim` checks `Attacker`’s balance (which is still 1 ETH because it hasn’t applied the effects of the first call)
8- `Victim` sends 1 ETH to `Attacker` (which triggers the default function and allows `Attacker` to reenter the `withdraw` function)
9- The process repeats until `Attacker` runs out of gas, at which point `msg.sender.call.value` returns without triggering additional withdrawals
10- `Victim` finally applies the results of the first transaction (and subsequent ones) to its state, so `Attacker`’s balance is set to 0
Mostrar todo
Copiar

El resumen es que, debido a que el saldo de la persona que llama no se establece en 0 hasta que se complete la ejecución de la función, las invocaciones posteriores tendrán éxito y permitirán que la persona que llame retire su saldo varias veces. Este tipo de ataque se puede utilizar para drenar un contrato inteligente de sus fondos, como lo que sucedió en el hackeo de 2016 de DAO(opens in a new tab). Los ataques de reentrada siguen siendo un problema crítico para los contratos inteligentes hoy en día, como muestran las listas públicas de explotaciones de reentrada(opens in a new tab).

Cómo prevenir los ataques de reentrada

Un enfoque para lidiar con la reentrada es seguir el patrón checks-effects-interactions(opens in a new tab) (comprobaciones-efectos-interacciones). Este patrón ordena la ejecución de las funciones de manera que lo primero sea el código que realiza las comprobaciones necesarias antes de progresar con la ejecución, luego venga el código que manipula el estado del contrato y finalmente venga el código que interactúa con otros contratos o EOA.

El patrón de comprobaciones-efecto-interacción se utiliza en una versión revisada del contrato Victim que se muestra a continuación:

1contract NoLongerAVictim {
2 function withdraw() external {
3 uint256 amount = balances[msg.sender];
4 balances[msg.sender] = 0;
5 (bool success, ) = msg.sender.call.value(amount)("");
6 require(success);
7 }
8}
Copiar

Este contrato realiza una comprobación del saldo del usuario, aplica los efectos de la función withdraw() (estableciendo el saldo del usuario en 0) y procede a realizar la interacción (enviando ETH a la dirección del usuario). Esto garantiza que el contrato actualice su almacenamiento antes de la llamada externa, eliminando la condición de reentrada que permitió el primer ataque. El contrato Attacker todavía podría volver a llamar a NoLongerAvictim, pero dado que balances[msg.sender] se ha establecido en 0, los retiros adicionales generarán un error.

Otra opción es usar un bloqueo de exclusión mutua (comúnmente descrito como "mutex") que bloquee una parte del estado de un contrato hasta que se complete la invocación de una función. Esto se implementa utilizando una variable booleana que se establece en true antes de que la función se ejecute y se revierte a false después de realizar la invocación. Como se ve en el siguiente ejemplo, el uso de un mutex protege una función contra las llamadas recursivas mientras la invocación original todavía se está procesando, deteniendo efectivamente la reentrada.

1pragma solidity ^0.7.0;
2
3contract MutexPattern {
4 bool locked = false;
5 mapping(address => uint256) public balances;
6
7 modifier noReentrancy() {
8 require(!locked, "Blocked from reentrancy.");
9 locked = true;
10 _;
11 locked = false;
12 }
13 // This function is protected by a mutex, so reentrant calls from within `msg.sender.call` cannot call `withdraw` again.
14 // The `return` statement evaluates to `true` but still evaluates the `locked = false` statement in the modifier
15 function withdraw(uint _amount) public payable noReentrancy returns(bool) {
16 require(balances[msg.sender] >= _amount, "No balance to withdraw.");
17
18 balances[msg.sender] -= _amount;
19 bool (success, ) = msg.sender.call{value: _amount}("");
20 require(success);
21
22 return true;
23 }
24}
Mostrar todo
Copiar

También puede utilizar un sistema de pull payments(opens in a new tab) que requiera que los usuarios retiren fondos de los contratos inteligentes, en lugar de un sistema de "pagos push" que envíe fondos a las cuentas. Esto elimina la posibilidad de activar inadvertidamente el código en direcciones desconocidas (y también puede prevenir ciertos ataques de denegación de servicio).

Desbordamiento de enteros

Un desbordamiento de enteros, o valor superior al aceptable (en inglés, overflow), se produce cuando los resultados de una operación aritmética sobrepasan el rango aceptable de valores, lo que hace que el resultado se corra al valor representable más bajo. Por ejemplo, un uint8 solo puede almacenar valores de hasta 2^8-1=255. Las operaciones aritméticas que resulten en valores superiores a 255 se desbordarán y restablecerán uint a 0, de manera similar a como el odómetro de un coche se restablece a 0 una vez que alcanza el kilometraje máximo (999.999).

Los desbordamientos de enteros ocurren también a la inversa: los resultados de una operación aritmética pueden caer por debajo del rango aceptable (underflow). Digamos que intentó disminuir 0 en un uint8; el resultado simplemente se revertiría al valor máximo representable (255).

Tanto los desbordamientos de enteros hacia arriba o hacia abajo (overflows y undeflows) pueden conducir a cambios inesperados en las variables de estado de un contrato y dar lugar a una ejecución no planificada. A continuación se muestra un ejemplo que muestra cómo un atacante puede explotar el desbordamiento aritmético de overflow en un contrato inteligente para realizar una operación no válida:

1pragma solidity ^0.7.6;
2
3// This contract is designed to act as a time vault.
4// User can deposit into this contract but cannot withdraw for at least a week.
5// User can also extend the wait time beyond the 1 week waiting period.
6
7/*
81. Deploy TimeLock
92. Deploy Attack with address of TimeLock
103. Call Attack.attack sending 1 ether. You will immediately be able to
11 withdraw your ether.
12
13What happened?
14Attack caused the TimeLock.lockTime to overflow and was able to withdraw
15before the 1 week waiting period.
16*/
17
18contract TimeLock {
19 mapping(address => uint) public balances;
20 mapping(address => uint) public lockTime;
21
22 function deposit() external payable {
23 balances[msg.sender] += msg.value;
24 lockTime[msg.sender] = block.timestamp + 1 weeks;
25 }
26
27 function increaseLockTime(uint _secondsToIncrease) public {
28 lockTime[msg.sender] += _secondsToIncrease;
29 }
30
31 function withdraw() public {
32 require(balances[msg.sender] > 0, "Insufficient funds");
33 require(block.timestamp > lockTime[msg.sender], "Lock time not expired");
34
35 uint amount = balances[msg.sender];
36 balances[msg.sender] = 0;
37
38 (bool sent, ) = msg.sender.call{value: amount}("");
39 require(sent, "Failed to send Ether");
40 }
41}
42
43contract Attack {
44 TimeLock timeLock;
45
46 constructor(TimeLock _timeLock) {
47 timeLock = TimeLock(_timeLock);
48 }
49
50 fallback() external payable {}
51
52 function attack() public payable {
53 timeLock.deposit{value: msg.value}();
54 /*
55 if t = current lock time then we need to find x such that
56 x + t = 2**256 = 0
57 so x = -t
58 2**256 = type(uint).max + 1
59 so x = type(uint).max + 1 - t
60 */
61 timeLock.increaseLockTime(
62 type(uint).max + 1 - timeLock.lockTime(address(this))
63 );
64 timeLock.withdraw();
65 }
66}
Mostrar todo
Cómo prevenir los desbordamientos hacia arriba y hacia abajo

A partir de la versión 0.8.0, el compilador de Solidity rechaza el código que da como resultado desbordamientos de enteros, tanto overflows como underflows. No obstante, los contratos compilados con una versión inferior del compilador deben realizar comprobaciones de funciones que impliquen operaciones aritméticas o utilizar una biblioteca (por ejemplo, SafeMath(opens in a new tab)) que compruebe los desbordamientos en ambos sentidos.

Manipulación de oráculos

Los oráculos obtienen información fuera de la cadena y la envían en cadena para que los contratos inteligentes la usen. Con los oráculos, puede diseñar contratos inteligentes que interactúen con sistemas fuera de la cadena, como los mercados de capitales, ampliando en gran medida su aplicación.

Pero si el oráculo está dañado y envía información incorrecta en cadena, los contratos inteligentes se ejecutarán en función de entradas erróneas, lo que puede causar problemas. Esta es la base del "problema del oráculo", que se refiere a la tarea de asegurarse de que la información de un oráculo de cadena de bloques sea precisa, actualizada y oportuna.

Una preocupación de seguridad relacionada es el uso de un oráculo en cadena, como un exchange descentralizado, para obtener el precio al contado de un activo. Las plataformas de préstamos en la industria de las finanzas descentralizadas (DeFi) a menudo hacen esto para determinar el valor del colateral de un usuario para determinar cuánto puede pedir prestado.

Los precios de los DEX suelen ser precisos, en gran parte debido a que los arbitradores restauran la paridad en los mercados. No obstante, están abiertos a la manipulación, especialmente si el oráculo en cadena calcula los precios de los activos en función de los patrones comerciales históricos (como suele ser el caso).

Por ejemplo, un atacante podría inflar artificialmente el precio al contado de un activo obteniendo un préstamo flash justo antes de interactuar con su contrato de préstamo. Consultar el precio del activo en el DEX devolvería un valor más alto de lo normal (debido a la gran "orden de compra" del atacante que sesga la demanda del activo), lo que le permitiría pedir prestado más de lo permitido. Tales "ataques de préstamos flash" se han utilizado para explotar la dependencia de los oráculos de precios entre las aplicaciones DeFi, lo que ha costado a los protocolos millones en fondos perdidos.

Cómo prevenir la manipulación de los oráculos

El requisito mínimo para evitar la manipulación del oráculo(opens in a new tab) consiste en utilizar una red de oráculo descentralizada que obtiene información de múltiples fuentes para evitar un único punto de error. En la mayoría de los casos, los oráculos descentralizados tienen incentivos criptoeconómicos incorporados para alentar a los nodos de oráculos a que pasen información correcta, lo que los hace más seguros que los oráculos centralizados.

Si planea consultar a un oráculo en cadena precios de activos, considere el uso de uno que implemente un mecanismo de precio promedio ponderado en el tiempo (TWAP). Un Oráculo TWAP(opens in a new tab) consulta el precio de un activo en dos puntos diferentes en el tiempo (que puede modificar) y calcula el precio al contado en función del promedio obtenido. La elección de períodos de tiempo más largos protege su protocolo contra la manipulación de precios, ya que los pedidos grandes ejecutados recientemente no pueden afectar a los precios de los activos.

Recursos de seguridad de contratos inteligentes para desarrolladores

Herramientas para analizar contratos inteligentes y verificar la corrección del código

  • Herramientas y bibliotecas de prueba: Colección de herramientas y bibliotecas estándar de la industria para realizar pruebas unitarias, análisis estático y análisis dinámico en contratos inteligentes.

  • Herramientas de verificación formal: Herramientas para verificar la corrección funcional en contratos inteligentes y comprobar invariantes.

  • Servicios de auditoría de contratos inteligentes: Lista de organizaciones que proporcionan servicios de auditoría de contratos inteligentes para proyectos de desarrollo de Ethereum.

  • Plataformas de recompensa por errores: Plataformas para coordinar recompensas de errores y premiar la notificación responsable de vulnerabilidades críticas en los contratos inteligentes.

  • Fork Checker:(opens in a new tab) Una herramienta en línea gratuita para comprobar toda la información disponible sobre un contrato bifurcado.

  • ABI Encoder:(opens in a new tab) Servicio en línea gratuito para codificar funciones de contratos y argumentos constructor de Solidity.

Herramientas para monitorear contratos inteligentes

Herramientas para la administración segura de contratos inteligentes

Servicios de auditoría de contratos inteligentes

Plataformas de recompensas por errores

Publicaciones de vulnerabilidades y explotaciones conocidas en los contratos inteligentes

Desafíos para aprender sobre seguridad en los contratos inteligentes

Mejores prácticas para proteger contratos inteligentes

Tutoriales sobre seguridad de contratos inteligentes

  • Cómo escribir contratos inteligentes seguros

  • Cómo usar Slither para encontrar errores en contratos inteligentes

  • Cómo utilizar Manticore para encontrar errores en contratos inteligentes

  • Directrices de seguridad de contratos inteligentes

  • Cómo integrar de forma segura su contrato de tokens con tokens arbitrarios

  • Cyfrin Updraft: curso completo sobre seguridad de contratos inteligentes y auditoría(opens in a new tab)

¿Le ha resultado útil este artículo?