El reentrancy solo puede ocurrir cuando tu smart contract llama a otro smart contract a través de una llamada de función o enviando ether.
Si no llamas a otro contrato o envías ether en medio de una ejecución, no puedes ceder el control de la ejecución, y el reentrancy no puede ocurrir.
function proxyVote(uint256 voteChoice) external {
voteContract.vote(voteChoice); // hands control to voteContract
alreadyVoted = true;
}
La parte complicada es que puede que no siempre sepas cuándo estás llamando a otro contrato. Por ejemplo, este código es en realidad reentrante si se usa dentro de un contrato ERC1155.
function purchaseERC1155NFT() external {
_mint(msg.sender, TOKEN_ID, 1, "");
erc20Token.transferFrom(msg.sender, address(this));
}
¿Por qué este mint de aspecto inofensivo es inseguro? Veamos el código en el ERC1155 de OpenZeppelin aquí.
function _mint(
address to,
uint256 id,
uint256 amount,
bytes memory data
) internal virtual {
require(to != address(0), "ERC1155: mint to the zero address");
address operator = _msgSender();
uint256[] memory ids = _asSingletonArray(id);
uint256[] memory amounts = _asSingletonArray(amount);
_beforeTokenTransfer(operator, address(0), to, ids, amounts, data);
_balances[id][to] += amount;
emit TransferSingle(operator, address(0), to, id, amount);
_afterTokenTransfer(operator, address(0), to, ids, amounts, data);
_doSafeTransferAcceptanceCheck(operator, address(0), to, id, amount, data);
}
Código Solidity de ERC1155
_mint llama a _doSafeTransferAcceptanceCheck. Sigamos esa función.
function _doSafeTransferAcceptanceCheck(
address operator,
address from,
address to,
uint256 id,
uint256 amount,
bytes memory data
) private {
if (to.isContract()) {
try IERC1155Receiver(to).onERC1155Received(operator, from, id, amount, data) returns (bytes4 response) {
if (response != IERC1155Receiver.onERC1155Received.selector) {
revert("ERC1155: ERC1155Receiver rejected tokens");
}
} catch Error(string memory reason) {
revert(reason);
} catch {
revert("ERC1155: transfer to non-ERC1155Receiver implementer");
}
}
}
Código Solidity de IERC1155Receiver
Y ahí podemos ver que _mint finalmente intentará llamar a la función onERC1155Received en el contrato receptor. Ahora hemos cedido el control a otro contrato.
La herramienta slither detectará automáticamente las llamadas a funciones externas, por lo que deberías usarla.
Esperemos que esto no haga las cosas más confusas, pero un código de aspecto muy similar
function purchaseERC1155NFT() external {
_mint(msg.sender, AMOUNT);
erc20Token.transferFrom(msg.sender, address(this));
}
no es reentrante si se deriva de ERC20. Esto se debe a que, internamente, la función transferFrom en Solidity no realiza una llamada a una función externa, como puedes ver en su implementación.
function _transfer(
address from,
address to,
uint256 amount
) internal virtual {
require(from != address(0), "ERC20: transfer from the zero address");
require(to != address(0), "ERC20: transfer to the zero address");
_beforeTokenTransfer(from, to, amount);
uint256 fromBalance = _balances[from];
require(fromBalance >= amount, "ERC20: transfer amount exceeds balance");
unchecked {
_balances[from] = fromBalance - amount;
// Overflow not possible: the sum of all balances is capped by totalSupply, and the sum is preserved by
// decrementing then incrementing.
_balances[to] += amount;
}
emit Transfer(from, to, amount);
_afterTokenTransfer(from, to, amount);
}
Implementación de transfer de ERC20
ERC721
safeTransferFrom_safeMint
Por muy confuso que parezca, la palabra “safe” significa que está comprobando si la dirección receptora es un smart contract, y luego intenta llamar a la función “onERC721Received”. Las funciones transferFrom y _mint no hacen esto, por lo que no tienes que preocuparte por el reentrancy.
Esto no significa que no debas usar los métodos safeTransferFrom o _safeMint, significa que debes usar el patrón check-effects o reentrancy guards para prevenir el reentrancy si los usas.
Aquí hay un ejemplo sencillo de una función mint donde el atacante puede mintear todos los NFTs para sí mismo:
contract FooToken is ERC721 {
function mint() external payable {
require(msg.value == 0.1 ether);
require(!alreadyMinted[msg.sender]);
totalSupply++;
_safeMint(msg.sender, totalSupply);
alreadyMinted[msg.sender] = true;
}
}
ERC1155
safeTransferFrom_mintsafeBatchTransferFrom_mintBatch
Aún más confuso, _mint en ERC1155 no se comporta como _mint en ERC721. Se comporta como _safeMint en ERC721.
Nada es “safe” en ERC1155. Todos los métodos llaman al contrato receptor. No hay nada de malo en esta elección de diseño, solo significa que debes seguir el patrón check-effects o usar reentrancy guards — como deberías estar haciendo de todos modos.
Aquí hay un código vulnerable para ERC1155
contract FooToken is ERC1155 {
function mint(uint256 tokenId) external payable {
require(msg.value == 0.1 ether);
require(!alreadyMinted[msg.sender]);
totalSupplyForTokenId[tokenId]++;
_mint(msg.sender, totalSupplyForTokenId[tokenId], 1, "");
alreadyMinted[msg.sender] = true;
}
}
ERC 223, 677, 777 y 1363
No podemos cubrir cada variación propuesta de ERC20 aquí. El hecho de que transfer y transferFrom de ERC20 no resulten en reentrancy es genial, pero esto también crea problemas de UX donde un smart contract no puede saber si ha recibido un token ERC20. La lista anterior son algunas variaciones propuestas de ERC20 que intentan informar al smart contract receptor que han recibido tokens.
Esto también debería ser una advertencia al interactuar con tokens ERC20 no confiables. Podrían ser en realidad uno de estos estándares internamente y ser capaces de desencadenar un reentrancy.
Aquí está la línea donde ERC777 llama al contrato después de transferir los tokens: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.8/contracts/token/ERC777/ERC777.sol#L499
function _callTokensReceived(
address operator,
address from,
address to,
uint256 amount,
bytes memory userData,
bytes memory operatorData,
bool requireReceptionAck
) private {
address implementer = _ERC1820_REGISTRY.getInterfaceImplementer(to, _TOKENS_RECIPIENT_INTERFACE_HASH);
if (implementer != address(0)) {
IERC777Recipient(implementer).tokensReceived(operator, from, to, amount, userData, operatorData);
} else if (requireReceptionAck) {
require(to.isContract(), "ERC777: token recipient contract has no implementer for ERC777TokensRecipient");
}
}
Línea de reentrancy de Solidity ERC777
ERC 1363 tiene una mejor UX para esto. La función transfer regular se comporta como un ERC20 normal, por lo que no tenemos ningún problema furtivo de reentrancy. Sin embargo, si queremos alertar al contrato de que recibió tokens, utilizamos el método transferAndCall.
El reentrancy en ERC777 ha ocurrido en el mundo real y puede ser bastante catastrófico. Aquí tienes un ejemplo.
Al diseñar una aplicación que interactúa con tokens ERC20 arbitrarios, no asumas que transfer y transferFrom son no reentrantes.
Enviando Ether
Cuando envías ether mediante address.call(””), cedes el control al otro contrato.
Considera el siguiente ejemplo clásico
contract FaultyBank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
msg.sender.call{value: balances[msg.sender]}("");
balances[msg.sender] = 0;
}
}
Puede ser atacado de esta manera
contract RobTheBank {
IFaultyBank private bank;
constructor(IFaultyBank _bank) {
bank = _bank;
}
function attack() payable {
bank.deposit{value: 1 ether}()
bank.withdraw();
}
fallback() external payable {
if (address(bank).balance >= 1 ether) {
bank.withdraw(); // reenterancy attack here
}
}
}
Debido a que balances[msg.sender] se establece en cero después de enviar el saldo, el atacante puede seguir retirando 1 ether (robando a otros usuarios), hasta que el balance esté por debajo de 1 ether.
Cómo transfer y send previenen el reentrancy, y por qué no deberías usarlos
Como nota al margen, los métodos transfer() y send() no son reentrantes aunque pueden desencadenar las funciones fallback y receive. Esto se debe a que limitan el gas reenviado a 2300 de gas. Esto no es suficiente para que el contrato malicioso vuelva a entrar en el contrato víctima.
Sin embargo, generalmente se considera una mala práctica usar estos métodos. Digamos que tienes un smart contract que intenta pagar un préstamo en otro smart contract. Si pagas el préstamo con transfer o send, el contrato de préstamo no tendrá suficiente gas para registrar que el préstamo fue pagado.
El hackeo de The DAO en 2016 fue casi fatal para el ecosistema de Ethereum, por lo que los diseñadores introdujeron estas funciones para evitar que volviera a suceder.
Transfer y send solo reenvían 2300 de gas cuando se utilizan. Ethereum no permite el almacenamiento de variables cuando hay menos de 2300 de gas disponible (fuente), por lo que esto significa que el contrato atacante no puede causar un cambio de estado permanente.
El problema con transfer y send es que muchos contratos pueden querer reaccionar deliberadamente a la recepción de ether. Por ejemplo, supongamos que tienes un prestamista descentralizado y quieres devolverle el préstamo enviando Ether. El contrato prestamista ve que el ether proviene del prestatario y marca su préstamo como pagado. Sin embargo, no puede hacer eso si lo privas de gas. Puedes leer más sobre por qué no deberías usar estas funciones aquí.
Puede parecer extraño que Solidity tenga características que no deberías usar, pero esto es parte de nuestra comprensión en evolución de las mejores prácticas de blockchain. Parecía una buena idea en ese momento prevenir el reentrancy limitando el gas, pero resulta que no podemos predecir cuáles serán los costos futuros del gas. Escribir el gas de forma fija (hardcoding) se considera una mala práctica, ya que el valor de gas de los opcodes puede haber cambiado.
Cross-function reentrancy. El reentrancy no tiene que entrar en la misma función
Cuando el contrato víctima realiza una llamada de función al contrato externo en el momento equivocado, el contrato atacante no tiene necesariamente que volver a entrar en la misma función que lo llamó. De hecho, si dos funciones son reentrantes, el atacante puede “hacer trampolín” (también llamado recursión mutua) entre las funciones. Algunos ingenieros se refieren a esto como cross-function reentrancy. Aquí hay un ejemplo de un contrato que es vulnerable a esto.
contract CrossFunctionReentrancyVulnerable {
// don't allow people to swap more than once every 24 hours
mapping(address => uint256) public lastSwap;
function swapAForB() {
require(block.timestamp - lastSwap[msg.sender] >= 1 days);
governanceTokenERC20.mint(msg.sender, AMOUNT);
tokenAerc777.transferFrom(msg.sender, address(this));
tokenBerc777.transferFrom(address(this), msg.sender);
lastSwap[msg.sender] = block.timestamp;
}
function swapBForA() {
require(block.timestamp - lastSwap[msg.sender] >= 1 days);
governanceTokenERC20.mint(msg.sender, AMOUNT);
tokenBerc777.transferFrom(msg.sender, address(this));
tokenAerc777.transferFrom(address(this), msg.sender);
lastSwap[msg.sender] = block.timestamp;
}
}
En el código anterior, los usuarios pueden intercambiar el token A por el B (y viceversa) y ser recompensados con tokens de gobernanza. Sin embargo, el contrato (intenta) limitarlos a intercambiar cada 24 horas para que los tokens de gobernanza no se minteen demasiado rápido.
Los tokens ERC777 pueden ser reentrantes como se señaló anteriormente, pero hacer un reentrancy simple en una función no funcionará porque el atacante se quedará sin tokenA o tokenB.
Sin embargo, si el atacante intercambia repetidamente A por B, entonces puede mintear todos los tokens de gobernanza para sí mismo.
En este caso, hemos hecho que el token de gobernanza sea un token ERC20 para que el atacante no pueda volver a entrar en la misma función. Sin embargo, cuando se ejecuta transferFrom(address(this), msg.sender), el atacante obtiene el control antes de que se actualice el mapping lastSwap.
Read only Reentrancy, también conocido como cross contract reentrancy
El read only reentrancy entró en la mente popular de los desarrolladores en 2022 cuando una charla en la ETH Devcon explicó una vulnerabilidad en Curve finance.
El read only reentrancy es solo un cambio de nombre (rebrand) de una vulnerabilidad ya conocida, el cross contract reentrancy.
Si el contrato Foo depende del estado de otro contrato Bar, y Bar no produce los valores de estado correctos a mitad de la transacción, entonces Foo puede ser engañado.
En el caso de Curve finance, no fue Curve el que fue explotado. Fueron los contratos que dependían de él. Funciona aproximadamente así:
- El atacante deposita ether y otros tokens ERC20 en Curve. Curve mintea tokens de liquidez al atacante.
- El atacante retira liquidez quemando los tokens de liquidez.
- Curve devuelve el ether antes de devolver los tokens ERC20.
- Cuando Curve devuelve Ether, el atacante recupera el control y realiza una operación en otro contrato
- El contrato que depende de Curve le pide a Curve la relación de precios entre los tokens de liquidez, ether y los otros tokens ERC20. Debido a que los tokens de liquidez han sido quemados y el Ethereum ha sido devuelto al atacante, pero los tokens ERC20 todavía están en Curve, el cálculo de los precios es incorrecto en este estado exacto del tiempo.
- La transacción se completa y Curve devuelve los tokens ERC20, y el precio calculado ahora es correcto.
El read only reentrancy es muy similar a un ataque de flash loan, y por lo general necesita un flashloan para ser efectivo.
Hay dos formas de defenderse contra el read only reentrancy o el cross contract reentrancy. Una es hacer público el reentrancy lock o hacer que las funciones view también sean no reentrantes. La función view que informa los precios está en un estado incorrecto en el momento en que el usuario retira parte de la liquidez. Así que el exchange puede bloquear a los usuarios para que no usen la función view mientras se está retirando la liquidez. Si el reentrancy lock es público, entonces una aplicación que depende de la función view puede comprobar si un retiro de liquidez está en progreso comprobando el reentrancy lock. Si el ether ha sido enviado, pero los tokens ERC20 aún no han sido retirados, entonces el reentrancy lock estará activado porque la función de retiro de liquidez aún no se ha completado.
Ten en cuenta que esta vulnerabilidad requiere enviar una secuencia de activos que pueden desencadenar otras funciones. En el caso de Curve descrito anteriormente, enviaron Ether antes de enviar tokens ERC20. Sin embargo, podría ocurrir algo similar si se enviaran tokens ERC777.
Más Recursos
Una lista actualizada de ataques de reentrancy en el mundo real:
https://github.com/pcaversaccio/reentrancy-attacks
Documentación anterior a 2022 sobre cross-contract reentrancy (read only reentrancy)
https://inspexco.medium.com/cross-contract-reentrancy-attack-402d27a02a15
Ejercicios de práctica:
Reentrancy en ERC 223:
https://capturetheether.com/challenges/miscellaneous/token-bank/
Ethernaut:
https://ethernaut.openzeppelin.com/level/10
(ten en cuenta que este ataque de reentrancy no funciona en Solidity 0.8.0 o superior porque el underflow en el balance resultará en la reversión de la transacción)
¿Interesado en aprender más? ¡Echa un vistazo a nuestro Solidity Bootcamp!
Publicado originalmente el 16 de diciembre de 2022