Casi todos hemos usado Google Sheets o Microsoft Excel para ingresar datos para algún cálculo. Supongamos que desea ingresar los nombres de los empleados, sus números de teléfono, títulos y el salario que ganan.
En su forma más simple, así es como se vería un registro o caso, en Hojas de cálculo o Excel:
Como puede ver, tanto el nombre como el título del empleado consisten en texto, mientras que el número de teléfono y el salario consisten en una secuencia de números.
Entonces, desde un punto de vista semántico, nosotros, como humanos, entendemos lo que significan estos campos en el mundo real y podemos diferenciarlos.
Claramente, si bien no necesita un título en Ciencias de la Computación para notar la diferencia, ¿cómo procesa estos datos un compilador o intérprete?
Aquí es donde entran los tipos de datos, y es algo que los programadores se toman el tiempo de especificar o no, según el lenguaje de programación en el que codifican.
En otras palabras, los puntos de datos bajo el nombre del empleado y el título se denominan cadenas. Por supuesto, el salario es claramente un número entero, en virtud de no tener puntos decimales. En pocas palabras, estos son tipos de datos que deben declararse como tales cuando codifica, de modo que solo se realicen las operaciones correctas asociadas con ese tipo de datos.
Así es como declaramos un tipo de dato entero en Solidity:
Dicho esto, el campo Número de teléfono en la hoja de cálculo anterior contiene un punto de datos que se usará como una cadena única, pero esa discusión es para otro día. Por ahora, nuestro enfoque estará en el tipo de datos primitivo con el que todos hemos realizado operaciones aritméticas básicas.
Sí, estamos hablando del tipo de datos enteros que, si bien son importantes para las operaciones aritméticas clave, tienen un rango limitado para cualquier cálculo.
Probablemente, el ejemplo más popular de desbordamiento de enteros en el mundo real ocurre en los vehículos. También conocido como odómetro, estos dispositivos generalmente rastrean cuántas millas ha viajado un vehículo.
Entonces, ¿qué sucede una vez que el valor de las millas recorridas alcanza el valor entero sin signo de 999999 en un odómetro de seis dígitos?
Idealmente, una vez que se agrega otra milla, este valor debería llegar a 1000000, ¿verdad? Pero esto no sucede ya que existe una provisión para un séptimo dígito.
En cambio, el valor de las millas recorridas se restablece a 000000, como se muestra a continuación:
Por definición, dado que el séptimo dígito no está disponible, se produce un "desbordamiento" ya que no se representa el valor exacto.
Entiendes la imagen, ¿verdad?
Por el contrario, también puede ocurrir lo contrario aunque esto no sea tan común. En otras palabras, cuando el valor registrado es menor que el valor mínimo disponible en el rango y lo que también se conoce como 'subdesbordamiento'.
Como todos sabemos, las computadoras almacenarán números enteros en la memoria como su equivalente binario. Ahora, en aras de la simplicidad, supongamos que está utilizando un registro de 8 bits.
= 2⁸*1 + 2⁷*1 + 2⁶*1 + 2⁵*1 + 2⁴*1 + 2³*1 + 2²*1 + 2¹*1 + 2⁰*1
= 256 + 128 + 64 + 32 + 16 + 8 + 4 + 2 + 1
= 111111111
Donde cada bit es 1, y como puede ver, no puede almacenar un valor mayor.
Por otro lado, si desea almacenar el número 0 en el registro de 8 bits, así se vería:
= 2⁸*0 + 2⁷*0 + 2⁶*0 + 2⁵*0 + 2⁴*0 + 2³*0 + 2²*0 + 2¹*0 + 2⁰*0
= 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0
= 000000000
Donde cada bit es 0, lo que debería indicarle que no puede almacenar un valor inferior.
En otras palabras, el rango de números enteros permitido para dicho registro de 8 bits es 0–511. Entonces, ¿es posible almacenar el número entero 512 o -1 en dicho registro?
Por supuesto que no. Como resultado, almacenará un valor que se asemeja al valor de reinicio de las millas recorridas en el ejemplo del odómetro, pero como valores binarios.
Claramente, necesitaría registros con algunos bits más para acomodar ese número cómodamente. O bien, arriesgarse a la situación de desbordamiento una vez más.
En el caso de enteros con signo, también almacenamos enteros negativos. Entonces, cuando intentamos almacenar un número que es más pequeño que el rango aceptado, o menor que cero, como se muestra arriba, se produce un desbordamiento.
Una vez más, dado que el objetivo de realizar cualquier cálculo es obtener resultados deterministas, en el mejor de los casos esto puede ser molesto, pero en el peor de los casos puede causar la pérdida de millones. Particularmente, cuando estos errores de desbordamiento o subdesbordamiento de enteros ocurren en contratos inteligentes.
Si bien el desbordamiento y el subdesbordamiento de enteros han existido durante décadas, su existencia como un error en un contrato inteligente ha aumentado las apuestas. Cuando los atacantes hacen uso de tales errores, pueden agotar el contrato inteligente de grandes cantidades de tokens.
Probablemente la primera vez que ocurrió este tipo de error fue con el bloque 74638, que creó miles de millones de Bitcoin para tres direcciones. Tomaría horas resolver este error por medio de una bifurcación suave y que descartó el bloque, invalidando así la transacción.
Por un lado, se rechazaron transacciones con un valor superior a 21 millones de bitcoins. Esto no fue diferente para las transacciones de desbordamiento, muy parecidas a la que envió tanto dinero a las tres cuentas antes mencionadas.
Sin embargo, los contratos inteligentes de Ethereum también han experimentado desbordamiento y subdesbordamiento de enteros, y BeautyChain también es un ejemplo destacado.
En este caso, el contrato inteligente contenía una línea de código defectuosa:
Como resultado, los atacantes teóricamente podían recibir una cantidad ilimitada de tokens BEC, que teóricamente podrían ascender a un valor de (2²⁵⁶)-1.
Ahora, veamos otro ejemplo de un contrato inteligente en el que se produce un desbordamiento/subdesbordamiento de enteros.
A primera vista, hay dos contratos que interactúan en este ejemplo, y que demuestra lo que sucede en el caso de un desbordamiento de enteros.
Como puede ver a continuación, el contrato TimeLock le permite depositar y retirar fondos pero con una diferencia: solo puede realizar este último después de un período de tiempo. En este caso, solo puede retirar sus fondos dentro de una semana.
Sin embargo, una vez que llama a la función de ataque en el contrato de ataque, el bloqueo de tiempo en su lugar ya no es efectivo y es por eso que el atacante puede retirar el monto del saldo de inmediato.
En otras palabras, debido a que se produce un desbordamiento de enteros con la instrucción type(uint).max+1-timeLock.locktime(address(this)), se elimina el bloqueo de tiempo.
Por ejemplo, una vez que haya implementado ambos contratos inteligentes utilizando el código anterior, puede probar si el bloqueo de tiempo se mantiene invocando las funciones de depósito y retiro en el contrato de TimeLock, como se muestra a continuación:
Como puede ver, al seleccionar una cantidad de 2 Ether, obtenemos el saldo del contrato inteligente de 2 Ether que se muestra arriba:
Específicamente, la dirección específica que contiene el saldo de 2 Ether se puede verificar agregando la dirección en el campo de la función de saldos y haciendo clic en el botón de saldos:
Sin embargo, como se mencionó anteriormente, aún no puede retirar estos fondos debido al bloqueo de tiempo establecido. Cuando mira la consola después de presionar la función de retiro, encontrará un error indicado por el símbolo rojo 'x'. Como puede ver a continuación, el contrato proporciona el motivo de este error: "Tiempo de bloqueo no vencido":
Ahora, veamos el contrato de ataque desplegado, como se muestra a continuación:
Ahora, para invocar la función de ataque, debe depositar un valor de 1 Ether o más. Entonces, en este caso, hemos seleccionado 2 Ether, como se muestra a continuación:
Después de esto, presiona 'atacar'. Encontrará que los 2 Ether que depositó se retirarán inmediatamente y se agregarán al contrato de Ataque como lo demuestra el saldo de 2 Ether a continuación:
Claramente, esto no se supone que suceda debido al hecho de que el bloqueo de tiempo prolongado debería entrar en vigencia tan pronto como realice el depósito. Por supuesto, como sabemos, la instrucción type(uint).max+1-timeLock.locktime(address(this)) reduce el tiempo de bloqueo mediante el uso de la función IncreaseLockTime. Esta es precisamente la razón por la que podemos retirar el saldo de Ether de inmediato.
Lo que nos lleva a la pregunta obvia: ¿existen formas de corregir la vulnerabilidad de desbordamiento y subdesbordamiento de enteros?
Al reconocer que la vulnerabilidad de desbordamiento/subdesbordamiento de enteros puede ser devastadora, se implementaron un par de correcciones para este error. Veamos estas dos correcciones y cómo funcionan en torno a dicho error:
Open Zeppelin, como organización, ofrece mucho cuando se trata de tecnología y servicios de ciberseguridad, con la biblioteca SafeMath como parte de su repositorio de desarrollo de contratos inteligentes. Este repositorio contiene contratos que se pueden importar a su código de contrato inteligente, siendo la biblioteca SafeMath uno de ellos.
Veamos cómo una de las funciones dentro de SafeMath.sol verifica el desbordamiento de enteros:
Ahora, una vez que ha tenido lugar el cálculo de a+b, se comprueba si c<a tiene lugar. Por supuesto, esto solo sería cierto en el caso de un desbordamiento de enteros.
Con la versión del compilador de Solidity llegando a 0.8.0 y superior, ahora se integran comprobaciones de desbordamiento y subdesbordamiento de enteros. Por lo tanto, aún se puede usar esta biblioteca para verificar esta vulnerabilidad, tanto cuando se usa el lenguaje como esta biblioteca. Por supuesto, si su contrato inteligente requiere una versión del compilador inferior a 0.8.+, entonces debe usar esta biblioteca para evitar el desbordamiento o el desbordamiento.
Ahora, como se mencionó anteriormente, si para su contrato inteligente está utilizando una versión del compilador que es 0.8.0 y superior, esta versión tiene un verificador incorporado para tal vulnerabilidad.
De hecho, solo para verificar si funciona con el contrato inteligente anterior, al cambiar la versión del compilador a "^0.8.0" y volver a implementarlo, se recibe el siguiente error 'revertir':
Por supuesto, no se realiza ningún depósito de 2 Ether, lo que se debe a la verificación del desbordamiento del valor de bloqueo de tiempo. Como resultado, no es posible realizar retiros debido a que no se depositaron fondos en primer lugar.
Sin duda, la llamada a la función Attack.attack() no ha funcionado aquí, ¡así que todo está bien!
Si hay algo que debería deducir de esta extensa publicación de blog, es que ignorar esta vulnerabilidad, como la del ataque BEC, puede resultar costoso. Como también puede ver, si no se marca, es fácil que se produzcan errores no maliciosos. O simplemente para que los piratas informáticos exploten esta vulnerabilidad.
Hablando de eso, y utilizando nuestra comprensión de cómo se produjo el ataque BEC, reconocer esta vulnerabilidad puede ser de gran ayuda para prevenir cualquier ataque al escribir sus contratos inteligentes, gracias a las correcciones que se ofrecen. Incluso si hay varias otras vulnerabilidades de contratos inteligentes que acechan para hacerte tropezar.