Los contratos inteligentes son códigos que se ejecutan automáticamente y constituyen la columna vertebral del ecosistema Web3. Los contratos inteligentes actúan como los pilares fundamentales del ecosistema Web3, equilibrando con precisión miles de millones en una red abierta. Hoy hablaremos de «delegatecall», una de las vulnerabilidades más comunes que afectan a los contratos inteligentes. Este es un buen punto de partida si quieres aprender sobre Solidity y cómo auditar contratos inteligentes. Este artículo forma parte de una serie dedicada a la auditoría de contratos inteligentes en Solidity. La serie abordará las vulnerabilidades y los recursos que utilizan los auditores de contratos inteligentes.
Delegatecall
En Solidity, existen dos formas principales de interactuar con las funciones de un contrato y enviarles mensajes. Estos métodos se denominan «Call» y «DelegateCall».
La función «Call» (o código de operación) se utiliza para iniciar interacciones entre contratos mediante el envío de mensajes externos. Cuando utilizas la función «Call» , tu código se ejecuta en el contexto del contrato o la función externa, ya sea como iniciador o como destinatario de la llamada. Esta función resulta muy útil para tareas como la transferencia de gas o de ether, siempre y cuando se proporcionen los parámetros correctos.
DelegateCall funciona de manera similar a Call, pero con una diferencia fundamental en su ejecución: DelegateCall opera dentro del contexto del llamante, en lugar de hacerlo en el del receptor. A diferencia de Call, DelegateCall conserva los valores originales de msg.sender y msg.value. En esencia, DelegateCall mantiene intacto el contexto del llamante. Es importante asegurarse de que la estructura de almacenamiento coincida con la del llamante y la del receptor cuando se utilice DelegateCall.
A pesar de las diferencias aparentemente sencillas entre `Call ` y `DelegateCall`, el uso de `DelegateCall` resulta bastante complicado y ` ` puede provocar resultados inesperados en tu código, lo que da lugar a experiencias no deseadas.
Al utilizar DelegateCall, hay dos aspectos fundamentales que hay que tener en cuenta:
- DelegateCall mantiene el contexto, que incluye el almacenamiento y los datos de quien realiza la llamada.
- La estructura de almacenamiento debe ser la misma tanto en el contrato que realiza la llamada como en el contrato invocado mediante DelegateCall.
Contrato inteligente de la víctima
Veamos cómo mantiene el contexto DelegateCall:
En el fragmento de código proporcionado, el contrato «Victim» utiliza «DelegateCall» para ejecutar una llamada. A primera vista, podría parecer que el código no permite que se realicen cambios en el propietario del contrato «Victim ». Sin embargo, esta primera impresión puede ser engañosa, ya que un actor malintencionado podría aprovechar vulnerabilidades para hacerse con el control del contrato. Veamos cómo puede suceder esto:
El contrato inicializa la variable de estado «owner» dentro del constructor e incluye una función de reserva. Al examinar el código, resulta evidente que la función de reserva utiliza «DelegateCall». Esta función redirige la llamada a la variable de estado «lib ». A primera vista, esta acción podría parecer inofensiva, pero sus verdaderas consecuencias requieren una investigación más profunda.
Entonces, ¿cómo podemos hacer frente al «contrato de víctima »?
Contrato inteligente del atacante
Para tomar el control del contrato «Victim» o cambiar quién es su titular , tenemos que cambiar la dirección del titular por la del atacante. Para ello, tenemos que encontrar la forma de comunicarnos con el contrato «Victim» pase lo que pase. Esto se puede hacer utilizando la función de reserva.
Creemos un nuevo contrato y llamémoslo «AttackerContract»:
Si observamos el código anterior, el primer paso consiste en crear una variable que almacene la dirección del contrato «Attacker ». El valor de esta variable se establecerá durante el despliegue del contrato y se introducirá en el constructor.
Dentro del contrato, existe una función llamada attack(). Esta función inicia una llamada al contrato Attacker. Al examinarlo más detenidamente, observamos que intenta invocar la función abc() en un contrato totalmente independiente. Curiosamente, la función attack() utiliza la firma de la función abc() como su msg.data.
Esta maniobra, ejecutada por la función attack(), activa la función fallback() dentro del contrato Attacker. Si recordamos brevemente, la función fallback() ejecuta una llamada DelegateCall al contrato Lib, reenviándole el msg.data.
Entonces, ¿cómo afecta esto al contrato «Attacker» ?
Dado que la función de reserva transfiere msg.data —que corresponde a la función abc() — al contrato Lib , se activa la función abc(). En consecuencia, la función abc()actualiza la variable «owner ». Como la función «delegatecall» ejecuta su código utilizando el almacenamiento del contrato Victim, la variable «owner» modificada es la que pertenece al contrato Victim. La función abc() modifica la variable «owner» para que coincida con «msg.sender». Dado que «msg.sender» hace referencia al iniciador del contrato «Victim», concretamente «AttackerContract», la nueva designación de «owner» pasa a ser «AttackerContract».
Ahora, veamos cómo funciona la gestión del almacenamiento en Solidity:
Para comprender esta vulnerabilidad, es necesario saber cómo gestiona Solidity las variables de estado.
Hemos visto que, cuando se utiliza una llamada a un delegado (DelegateCall) para modificar el almacenamiento en Solidity, las variables de estado deben declararse en el mismo orden. Sin embargo, ¿qué ocurre si no respetamos el orden correcto o especificamos un tipo erróneo para estas variables? Las consecuencias pueden ser indeseables.
El código proporcionado contiene dos contratos. El contrato inicial, «Lib», introduce una variable de estado denominada «number». Además, incluye una función llamada «action() » que simplemente actualiza el valor de «number ».
Pasando al segundo contrato, «Victim», se definen tres variables de estado: «lib», «owner» y «number». En el constructor, el contrato asigna el valor de «lib» a la dirección del contrato «Lib» y designa el valor de «owner» como «msg.sender».
El contrato «Victim» también incorpora una función denominada «action()» que reproduce el comportamiento de «Lib.action()». En esta función se inicia una llamada «DelegateCall» utilizando la dirección del contrato «Lib». A continuación, la solicitud «DelegateCall» se dirige a la función «action()» del contrato «Lib ».
Al analizar esto, podemos observar que el contrato «Lib» solo declara una variable de estado, mientras que el contrato «Victim» declara tres. Esta discrepancia constituye una vulnerabilidad y un posible punto de partida para los atacantes que pretendan explotar el contrato «Victim ».
Ahora veamos cómo se puede atacar el contrato «Victim» debido a este error:
En el fragmento de código proporcionado, el contrato «Attacker» contiene tres variables de estado estructuradas de la misma forma que las del contrato «Victim ». Además, hay una variable de estado que almacena la dirección del contrato «Victim», cuyo valor real se asigna durante la ejecución del constructor.
El atacante define una función «attack()» que realiza dos llamadas a la función «action()» dentro del contrato «Victim ».
En la primera llamada, el atacante proporciona su dirección como argumento a Victim.action(). Sin embargo, dado que Victim.action() espera un argumento de tipo uint, el atacante, astutamente, convierte su dirección a uint. Al ejecutarse la primera llamada, se activa la función action() dentro del contrato Victim. El valor numérico pasa a ser la dirección del atacante, convertida a uint. A continuación, esta función inicia una llamada DelegateCall al contrato «Lib» , que a su vez llama a la función action() que contiene. Esta función actualiza la variable de estado del contrato «Lib» , estableciéndola en la dirección del atacante.
Debido a la estructura específica del almacenamiento, solo se actualiza la primera variable dentro del contrato «Victim». Dado que la variable inicial del contrato «Victim» representa la dirección del contrato «Lib », esta se sustituye por la dirección del contrato «Attacker ».
Una vez finalizada la ejecución de la primera llamada, la atención se centra en la segunda llamada, victim.action(5). Esto es lo que ocurre a continuación:
Esta llamada activa la función action() dentro del contrato «Victim» , tal y como se esperaba. Sin embargo, es importante señalar que victim.action() utiliza delegatecall con el valor almacenado en la variable de estado «lib ». Teniendo en cuenta que la variable «lib» se modificó en la llamada anterior, la función ahora realiza una llamada delegatecall al contrato «Attacker ».
Al producirse dicha llamada al delegado, se activa la función Attacker.action(). En el código correspondiente, se actualiza la variable de estado «owner ». Sin embargo, surge una pregunta fundamental: ¿qué variable de estado «owner» se modifica?
Dado que toda la operación se desarrolla en el contexto del contrato «Victim», la variable de estado «owner», susceptible de ser modificada, pertenece al contrato «Victim ». Además, dado que «msg.sender» hace referencia a la dirección del atacante, la dirección del contrato «Victim» se transforma en la del atacante, lo que, en la práctica, convierte al atacante en el nuevo propietario del contrato «Victim ».
Una vez más, el contrato se ve comprometido debido a un uso incorrecto de DelegateCall.
Recomendaciones para Deligatecall
Para evitar este problema, puedes utilizar una biblioteca especial en tu código. Esta biblioteca se denomina «stateless», lo que significa que no retiene ningún dato entre un uso y otro. Es similar a utilizar una herramienta que se restablece a su estado original después de cada uso.
El uso de esta biblioteca sin estado te ayuda a garantizar que los contratos con los que trabajas no retengan accidentalmente datos erróneos ni mezclen información cuando se comunican entre sí. De este modo, creas una red de seguridad que evita que se produzca la vulnerabilidad de la llamada a «delegate» en tu contrato inteligente.
En conclusión
La función `delegatecall` puede acarrear pérdidas económicas considerables. El uso de una biblioteca sin estado ofrece un enfoque más inteligente y seguro para desarrollar tu contrato, al tiempo que mitiga los riesgos asociados a las vulnerabilidades de `delegatecall`. Es similar a emplear una herramienta especializada que garantiza que cada uso sea independiente y no interfiera con la información previa. Las auditorías de contratos inteligentes, los programas de recompensas por errores y las revisiones son fundamentales en todas las fases del desarrollo. Aumentan el número de personas que buscan vulnerabilidades y reducen la probabilidad de que se pasen por alto vulnerabilidades críticas.
Cuídate.
Únete a AuditOne como auditor: https://www.auditone.io/auditors
Reserva tu consulta gratuita sobre seguridad:
Google Calendar: https://calendar.app.google/Ai15eyQhiV5c1pBXA
Telegram: https://t.me/m_ndr
Artículos relacionados:
Auditoría de un contrato de Solidity: Episodio 1 — Ataque de reentrada
Auditoría de un contrato de Solidity: Episodio 3 - Análisis de seguridad
Auditoría de un contrato de Solidity: Episodio 4 - Pruebas
Auditoría de un contrato de Solidity: Episodio 5 - Herramientas de pruebas automatizadas
Auditoría de un contrato de Solidity: Episodio 6 - Frontrunning
Auditoría de un contrato de Solidity: Episodio 7 — Documentación y elaboración de informes
Auditoría de un contrato de Solidity: Episodio 8 - Ventajas de la auditoría
.avif)

.png)






