I. Armazenamento/Atribuição/Exclusão de variáveis:
1. Não armazene nenhuma informação confidencial na blockchain.
Detalhes da vulnerabilidade:
Devido à transparência da blockchain, quaisquer dados de contrato implantados na cadeia são visíveis e transparentes, mesmo para variáveis marcadas como privadas. Isso ocorre porque a visibilidade das variáveis privadas está limitada a funções e contratos externos, e qualquer usuário pode recuperar esses valores pesquisando-os nos dados da cadeia. Nesta situação, quaisquer tentativas de usar modificadores privados para garantir a confidencialidade não são seguras.
Existe um código de loteria simples como o seguinte:
contract Eocene{
mapping(address => bytes32) candidate;
uint private seed = 0x123413d;
function select() public{
bytes32 result = keccak256(abi.encodePacked(seed));
if(result == candidate[msg.sender]){
payable(msg.sender).transfer(1 ether);
}
}
}
// …
}
No código do contrato acima, mesmo que seed tenha sido declarada como uma variável privada, qualquer pessoa pode recuperar o valor de seed na cadeia usando o endereço do contrato e a posição do slot da variável seed. Ao fazer isso, pode-se calcular o valor correspondente e obter o ETH.
Correção:
Não armazene quaisquer valores críticos usados para verificação no contrato. Em vez disso, armazene esses valores fora da cadeia. Quaisquer valores armazenados na cadeia são transparentes, portanto, implemente apenas a lógica de verificação correspondente na cadeia.
2. Esteja ciente dos valores padrão das variáveis.
Detalhes da vulnerabilidade:
No Solidity, o valor inicial de uma variável é 0/false (falso). Neste caso, se o impacto do valor inicial da variável não for considerado ao fazer julgamentos com base em uma determinada variável, poderá levar a problemas de segurança correspondentes.
Considere o seguinte código de desbloqueio do airdrop:
contract Eocene{
mapping(address => bool) unlocked;
uint averageDrop;
address token;
function setAverageDrop() public {
averageDrop = 1000;
}
function drop() public {
if(unlocked[msg.sender] == false){
ERC20(token).transfer(msg.sender,averageDrop);
}
}
}
A intenção do código do contrato é distribuir tokens para todos os endereços desbloqueados. No entanto, ignora o fato de que, em Solidity, todos os valores iniciais das variáveis são 0/false. Em um tipo mapping (mapeamento), a chave é usada apenas para concatenar com o slot e calcular o endereço correspondente à chave armazenada usando keccak256. Isso significa que qualquer endereço, inicializado ou não, terá um armazenamento correspondente, e o valor inicial para esse armazenamento geralmente é 0/false.
Correção:
Em qualquer situação, não se baseie no valor padrão de uma variável para fazer julgamentos críticos, especialmente ao lidar com múltiplas variáveis aninhadas do tipo mapping. Evite estritamente a ocorrência de tais problemas.
3. Use “delete” para excluir valores de tipo de struct não utilizados.
Detalhes da vulnerabilidade:
Para qualquer tipo mapping, quando o tipo de campo de valor for struct e o valor correspondente não for mais necessário, use delete para excluir esse valor. Caso contrário, o valor ainda permanecerá no slot correspondente.
Considere o seguinte código:
contract Eocene{
struct Stake{
uint amount;
uint needReceive;
uint startTime;
}
mapping(address => Stake) stakes;
mapping(address => bool) staker;
function getStake() public{
Stake memory _stake = stakes[msg.sender];
(msg.sender).transfer(_stake.needReceive);
staker[msg.sender] = false;
// excluir stakes[msg.sender] // precisa fazê-lo, mas não faz
}
function calReceive() public{
require(staker[msg.sender],'not staker');
stakes[msg.sender].needReceive = stakes[msg.sender].amount * (block.time - stakes[msg.sender].startTime);
stakes[msg.sender].amount = 0;
}
}
No código de contrato acima, o ETH é calculado com base no valor e na duração do stake. No entanto, após a conclusão do stake, apenas o valor de staker[msg.sender] é definido como falso, enquanto os valores de stakes[msg.sender] correspondentes ainda existem. Portanto, um atacante pode chamar a função getStake() indefinidamente para obter ETH.
Correção:
É claro que o código acima também possui alguns outros problemas auxiliares que levam a vulnerabilidades. No entanto, você deve estar ciente de que, para variáveis do tipo struct armazenadas no armazenamento, todo o valor da struct deve ser excluído usando delete (ou todos os valores devem ser definidos como 0) quando não for mais necessário, caso contrário, o valor continuará a existir no slot correspondente.
II. Definição de Função:
1. Declare explicitamente a visibilidade da função.
Detalhes da vulnerabilidade:
A visibilidade padrão de uma função é pública. Para qualquer função, sua visibilidade deve ser explicitamente declarada para evitar vulnerabilidades causadas por negligência, especialmente ao chamar funções subjacentes com camadas aninhadas, para evitar que as funções subjacentes recebam visibilidade incorretamente atribuída devido a negligência.
Considere o seguinte exemplo de código vulnerável:
contract Eocene{
mapping(address => bool) whitelist;
function _a() {
payable(msg.sender).transfer(1 ether);
}
function a() public{
require(whitelist[msg.sender],'not in whitelist');
_a();
}
}
A função a() restringe os endereços da lista de permissões por meio de require e transfere fundos para o endereço correspondente. Normalmente, _a() não deve ser chamada externamente, mas aqui é erroneamente declarada como pública devido à falta de declaração de visibilidade explícita, tornando-a diretamente chamável externamente.
Correção:
Declare explicitamente a visibilidade de todas as funções, especialmente para funções que não podem ser chamadas diretamente do exterior, que devem ser explicitamente declaradas como protegidas ou privadas.
2. Ataque de reentrância
Detalhes da vulnerabilidade:
Para qualquer função, considere os problemas que podem surgir após a reentrância. A reentrância aqui inclui todos os problemas de reentrância causados por chamadas externas, como transfer/send/call/staticcall (transferência/envio/chamada/chamada estática).
Considere o seguinte código:
contract Fund {
/// @dev Mapping de ambas as partes do contrato.
mapping(address => uint) shares;
/// Retirada da sua parte.
function withdraw() public {
if (payable(msg.sender).send(shares[msg.sender]))
shares[msg.sender] = 0;
}
}
Para o contrato acima, quando msg.sender é malicioso, pode fazer com que msg.sender retire todo o saldo atual do contrato sem limitação. No entanto, também devemos estar cientes de que chamar transfer/send/call/staticcall e quaisquer funções de contrato externo podem causar problemas de reentrância.
Correção:
Dependendo da implementação específica do contrato, modifique primeiro a implementação da variável-chave. Por exemplo, no contrato acima, você pode primeiro registrar o valor de shares[msg.sender], definir shares[msg.sender] como 0 e, em seguida, realizar a operação de envio. Claro, isto também pode ser conseguido através de uma combinação de variáveis globais e modificadores.
III. Interação Externa
1. Lista restrita de endereços e nomes de funções para chamadas externas
Detalhes da vulnerabilidade:
Ao chamar uma função externa, o endereço do contrato e o nome da função devem ser limitados em circunstâncias razoáveis. Isso ocorre porque não é possível determinar se a função de contrato de qualquer endereço externo será alterada. Portanto, em possíveis situações como essa, o endereço do contrato e o nome da função para chamadas externas devem ser estritamente limitados para evitar, tanto quanto possível, problemas de segurança no contrato causados pela insegurança de contratos externos.
Considere o seguinte código:
contract Eocene{
function callExt(address _target,bytes calldata data) public{
_target.call(data);
}
function delegateCallExt(address _target,bytes calldata data) public{
_target.delegatecall(data);
}
}
A função callExt() é usada para chamar qualquer função em qualquer endereço. Isso pode facilmente levar a problemas de reentrância e, uma vez que o contrato tenha quaisquer ativos de token em qualquer carteira, a função de transferência do token correspondente pode ser chamada diretamente através desta função para transferir os ativos.
Se não houver restrições ao chamar a função delegateCallExt(), isso causar a destruição direta do contrato, fazendo com que todo o saldo do endereço do contrato seja transferido e o contrato seja destruído.
Correção:
Ao chamar qualquer contrato externo, considere primeiro se o endereço pode ser colocado na lista de permissões e, em seguida, examine se o nome da função do endereço especificado pode ser restringido.
2. Ao usar call, send, delegatecall, staticcall, não se baseie apenas em exceções para avaliar as chamadas externas, mas também use valores de retorno para determinar se a execução foi bem-sucedida.
Detalhes da vulnerabilidade:
As funções acima não serão revertidas devido a erros internos, apenas retornam uma mensagem de reversão. Sempre que usá-las, você deve avaliar o sucesso da execução pelo valor de retorno da função.
Considere o seguinte exemplo de código:
contract Eocene{
address token; //qualquer endereço de token
function deposit(uint amount) public{
token.call(abi.EncodeWithSignature("transferfrom(address from,address receipt,uint amount)"),msg.sender,address(this),amount);
mint(msg.sender,amount);
}
}
A função deposit() primeiro tenta transferir o token especificado de msg.sender para o endereço atual. Se a transferência for bem-sucedida, ela cunhará parte da moeda atual para msg.sender. Mas, como a função call() não reverte toda a transação quando falha, mesmo que nenhuma moeda seja transferida de msg.sender para o endereço atual, msg.sender ainda receberá o valor cunhado da moeda atual.
Correção:
O julgamento do resultado da execução de call/send/delegatecall/staticcall deve ser baseado em seus valores de retorno, não no fato de serem revertidos ou não.
IV. Controle de acesso:
1. Não baseie a verificação de identidade em tx.origin.
Detalhes da vulnerabilidade:
Não baseie a verificação de identidade em tx.origin, que é o iniciador de toda a transação e não muda com chamadas recursivas do contrato. Qualquer autenticação baseada em tx.origin não pode garantir que tx.origin seja msg.sender, e tal autenticação também aumenta o risco de segurança das contas dos usuários.
Considere o seguinte exemplo vulnerável:
contract Eocene{
mapping(address=>bool) whitelist;
function freeDeposit() public{
require(whitelist[tx.origin],'not in whitelist');
payable(msg.sender).transfer(1 ether);
}
}
Quando qualquer endereço na lista de permissões é induzido por algum link de phishing para chamar qualquer endereço e função de contrato malicioso aparentemente inofensivo, e o endereço malicioso chama a função freeDeposit do código de exemplo, os ativos que deveriam pertencer ao endereço da lista de permissões serão transferidos para o endereço de contrato malicioso.
Correção:
Não baseie a verificação de identidade em tx.origin. Para o código acima, mudar para payable(tx.origin).transfer(1 ether) não causará problemas. No entanto, a abordagem recomendada não é usar tx.origin para verificação de identidade, mas usar require(whitelist[msg.sender],'not in whitelist') para julgamento.
2. Não baseie julgamentos no valor de retorno extcodesize para contas EOS.
Detalhes da vulnerabilidade:
Durante a fase de inicialização do código do contrato, mesmo que o endereço seja um endereço de contrato, o valor de retorno extcodesize será 0. Se os julgamentos forem baseados nesse valor de retorno, o resultado será impreciso.
Considere o seguinte código:
contract Eocene{
function withdraw() public{
uint size;
assembly {
size := extcodesize(caller())
}
require(size==0,"not eos account");
msg.sender.transfer(1 ether);
}
}
Na função de retirada, o contrato de recurso espera restringir o acesso do token apenas às contas EOS, limitando-o através do valor de retorno extcodesize. No entanto, ignora o fato de que, durante a fase de inicialização do contrato, o valor de retorno extcodesize para o endereço do contrato também é 0. Isso leva a um julgamento impreciso, permitindo que qualquer endereço obtenha tokens do contrato.
Correção:
Não julgue com base no fato de um endereço externo ser um endereço de contrato em nenhum momento e tente garantir que o código do contrato funcione corretamente em qualquer tipo de conta.
V. Operações Aritméticas
1. Considere o overflow ao realizar qualquer operação numérica.
Detalhes da vulnerabilidade:
O problema de overflow refere-se ao overflow causado pela operação de números inteiros no contrato. O principal motivo é que qualquer tipo numérico tem seu comprimento máximo e, quando a operação de dois números inteiros ultrapassa seu valor máximo, a parte em overflow ficará truncada, causando problemas.
Considere o seguinte formato de código:
contract Eocene{
mapping(address=>uint) balanceof;
function withdraw(uint amount) public{
payable(msg.sender).transfer(amount);
balanceof[msg.sender] = balanceof[msg.sender]-amount;
require(balanceof[msg.sender] >= 0,'not enough balance');
}
}
Para a função acima, considere quando balanceof[msg.sender] <amount. Em virtude de o tipo de balanceof ser definido como um número inteiro sem sinal, o resultado final do cálculo causará um valor negativo do tipo int. Quando convertido para o tipo uint, torna-se um valor positivo muito grande. Nesse momento, a condição de restrição de require é contornada e o atacante pode roubar qualquer número de tokens do contrato.
Correção:
Use a biblioteca SafeMath ou verifique a exatidão do valor antes de cada cálculo para garantir que o resultado final do cálculo não causará overflow.
2.Tenha cuidado ao usar o tipo int ao realizar qualquer operação com números inteiros.
Detalhes da vulnerabilidade:
Ao realizar qualquer operação com números inteiros, tome cuidado para não converter tipos uint em tipos int para cálculos, a menos que você precise desta operação. Porque, ao converter um inteiro do tipo uint em um tipo int, algumas situações em que o tipo uint está em overflow serão inválidas no tipo int.
Considere o seguinte formato de código:
contract Eocene{
int public result;
uint public uresult;
function cal(uint _a, uint _b) public{
result = int(_a)-int(_b);
uresult = uint(result);
}
}
Ao compilar com Solidity versão 0.8.0 ou superior, se você chamar cal(0,1), mesmo que 0–1 cause overflow em uint, não causará reversão devido ao overflow no cálculo em int (porque o resultado de 0 –1 está dentro do intervalo do tipo int). No entanto, quando o valor do resultado é convertido de novo para o tipo uint, ele é o valor do resultado obtido pelo overflow real do cálculo do tipo uint, o que indiretamente causa a existência de problemas de overflow.
No entanto, deve-se observar que chamar cal(type(int). Min, type(int). Max) aqui ainda causará uma reversão porque o cálculo do inteiro excede o intervalo do tipo int.
Correção:
Tenha cuidado ao usar o tipo int ao realizar qualquer forma de operação com números inteiros. Se a própria operação com números inteiros em si precisar de overflow, considere usar uncheck para agrupar a operação do tipo uint para realizá-lo.
3. Em qualquer operação que possa perder precisão, estenda a precisão por meio da extensão.
Detalhes da vulnerabilidade:
Ao realizar qualquer operação com números inteiros, considere a possibilidade de perda de precisão causada por overflow e estenda sua precisão.
Considere o seguinte formato de código:
contract Eocene {
uint totalsupply;
mapping(address=>uint) balancesof;
uint BasePrice = 1e16;
function mint() public payable {
uint tokens = msg.value/BasePrice;
balancesof[msg.sender] += tokens;
totalsupply += tokens;
}
}
Considere o contrato acima. Na função mint, calcula-se a quantidade de tokens que devem ser obtidos através de msg.value/basePrice. Porém, devido ao problema de precisão de cálculo, a parte de msg.value menor que 1e16 ficará toda bloqueada no contrato, o que não só desperdiçará eth, mas também será muito ruim para a experiência do usuário.
Correção:
Em qualquer cálculo de número inteiro que possa ter perda de precisão, primeiro use *1eN para estender o número inteiro (N é o tamanho da precisão necessária).
Ⅵ. Aleatoriedade:
1. Não use dados previsíveis/manipuláveis na cadeia como sementes de números aleatórios.
Detalhes da vulnerabilidade:
Devido à natureza única da blockchain, não existem valores verdadeiramente aleatórios na cadeia. Portanto, nenhum dado na cadeia deve ser usado como valores aleatórios ou sementes de números aleatórios. Em vez disso, é melhor obter valores aleatórios fora da cadeia.
O exemplo de código a seguir demonstra essa vulnerabilidade:
contract Eocene{
function winner(bytes32 value) public payable{
require(msg.value > 0.5 ether,"not enough value");
if(value == keccak256(abi.encodePacked(block.timestamp))){
msg.sender.transfer(1 ether);
}
}
}
Para este contrato, um valor aleatório é calculado com base na marca temporal (timestamp) do bloco atual e é comparado com o valor enviado pelo usuário. Se os dois valores coincidirem, o usuário recebe uma recompensa. À primeira vista, parece ser uma situação aleatória baseada no tempo, mas, na realidade, qualquer usuário que utilize keccak256(abi.encodePacked(block.timestamp)) pode calcular o mesmo valor por meio de uma chamada de contrato e, em seguida, enviá-lo para o função winner() do contrato para obtenção de ETH. Além disso, devemos estar cientes de que block.timestamp é um valor que pode ser adulterado maliciosamente pelos mineradores e não é necessariamente honesto.
Correção:
Não use nenhum dado na cadeia (block.*/now) como um número aleatório ou semente de número aleatório. Considere obter valores aleatórios off-line por meio da Chainlink ou de outras fontes semelhantes.
Ⅶ. DOS:
1. Proíba qualquer operação que copie todo o array dinâmico para uma variável de memória.
Detalhes da vulnerabilidade:
O uso do tamanho da memória disponível pelo Solidity é muito menor que o armazenamento (0xffffffffffffffff). Qualquer comportamento que copie um array dinâmico como um todo para a memória pode exceder o tamanho da memória disponível e causar uma reversão.
Considere o seguinte código:
contract Eocene{
uint[] id;
function pop(uint amount) public{
require(amount>0,'not valid amount');
uint[] memory _id=id; // isto pode ser revertido devido ao limite de espaço da memória
for(uint i=0;i<_id.length;i++)
{
if(amount==_id[i]){
id[i] = 0;
}
}
}
function push(uint amount) public{
require(amount>0,'not valid amount');
id.push(amount);
}
}
Neste código, uint[] memory _id=id;
colocará o valor da variável uint[] id;
armazenada na memória. A função push() pode inserir valores em uint[] id;. No entanto, devido às limitações de espaço de memória do Solidity, uma vez que o comprimento de uint[] id;
exceda (0xffffffffffffffff-0x40)/0x20–1
, causará uso excessivo de memória, resultando em uma reversão. Isso significa que a função pop() do contrato pode não ser executada com sucesso, ou, em outras palavras, que qualquer função com a operação uint[] memory _id=id;
poderá não ser executada com sucesso.
Correção:
Nunca execute uma operação em que um array dinâmico mutável seja copiado para a memória. Além disso, observe que 0xffffffffffffffff é o tamanho máximo de memória disponível nas funções do Solidity e que qualquer função com uso de memória que exceda esse valor não será executada com êxito.
2. A condição de loop em qualquer loop “for” não pode ser baseada em variáveis modificáveis externamente.
Detalhes da vulnerabilidade:
Se a condição de qualquer loop for for baseada em uma variável modificável externamente, pode haver um problema de consumo excessivo de gás devido ao fato de a variável externa ser muito grande. Quando o consumo de gás é alto o suficiente para exceder a tolerância de cada chamador de contrato, pode ocorrer um ataque DOS.
Considere o seguinte código:
contract Eocene{
uint[] id;
function pop(uint amount) public{
require(amount>0,'not valid amount');
for(uint i=0;i<id.length;i++)
{
if(amount==id[i]){
id[i] = 0;
}
}
}
function push(uint amount) public{
require(amount>0,'not valid amount');
id.push(amount);
}
}
Aqui, eliminamos a operação de cópia do array do armazenamento para a memória, mas outro problema com este código é que o loop for é baseado no comprimento de uint[] id;
, e o comprimento de id só pode aumentar no contrato e não diminuir. Isso significa que o gás consumido pela função pop() continuará a aumentar e, quando o consumo de gás se tornar maior que o consumo máximo de gás que a função pop() pode tolerar, poucas pessoas executarão pop(), alcançando efetivamente um ataque DOS.
Correção:
Evite loops ilimitados com base em variáveis modificáveis externamente. Qualquer operação de loop deve ser capaz de determinar o comprimento máximo que pode ser executado, para evitar a ocorrência de ataques DOS.
3. Use try/catch para capturar exceções desconhecidas em loops.
Detalhes da vulnerabilidade:
Se houver a possibilidade de reversão devido a um endereço externo em qualquer loop interno, deve-se considerar a captura da reversão. Caso contrário, uma vez que a execução de um loop interno falhe, todo o consumo de gás anterior perde o sentido. Se o sucesso ou a falha de um loop interno puder ser controlado por um endereço externo, a falha na captura de possíveis exceções com try/catch poderá fazer com que a condição do loop nunca seja concluída, resultando em um ataque DOS.
O código de exemplo é o seguinte:
contract Eocene{
address[] candidates;
mapping(address=>uint) balanceof;
function claim() public{
for(uint i=0;i<candidates.length;i++)
{
address candidate = candidates[i];
require(balanceof[candidate]>0,'no balance');
payable(candidate).transfer(balanceof[candidate]);
}
}
}
O código usa um loop for para transferir fundos para cada candidato, mas não considera a situação em que qualquer candidato reverte diretamente nas funções fallback ou receive, fazendo com que o loop nunca seja executado com sucesso e implementando um ataque DOS.
Correção:
Em qualquer loop for que envolva uma chamada externa, e se for impossível determinar se a chamada será revertida, try/catch deve ser usado para tratar exceções e evitar ataques DOS causados por reversões.
VIII. Use um compilador de versão superior:
1. Compile contratos usando um compilador versão 0.8.17 ou superior.
Os contratos abaixo da versão 0.8.17 apresentam alguns problemas de vulnerabilidade de risco médio-alto que podem expor o código do contrato ao perigo. Esses problemas existem na fase de compilação e não são fáceis de detectar, principalmente quando os casos de teste são insuficientes. Recomenda-se usar uma versão superior do compilador para evitar esses problemas. Naturalmente, se você precisar usar uma versão prejudicada do compilador, certifique-se de compreender seus riscos e procure ajuda profissional de especialistas em segurança.
Para obter mais informações sobre vulnerabilidades específicas do compilador e seus danos, consulte:
[Solidity]
Sobre nós
Na Eocene Research, fornecemos insights sobre intenções e segurança por trás de tudo o que você sabe ou não sobre blockchain e capacitamos cada indivíduo e organização para responder a perguntas complexas com as quais nem sonhávamos antigamente.
Saiba mais: Site | Medium | Twitter
Este artigo foi escrito por Eocene|Security e traduzido por Isabela Curado Nehme. Seu original pode ser lido aqui.
Latest comments (0)