Ataques a smart contracts y cómo defenderse (I): Condiciones de carrera

En este artículo voy a dar una lista de los ataques conocidos y más frecuentes a smart contracts, así cómo mecanismos para defendernos de éstos.

Resultado de imagen de smart contract attack

Condiciones de carrera

Las condiciones de carrera se producen en situaciones de concurrencia en las que se compite por los recursos compartidos y la salida o estado de un proceso es dependiente de que una secuencia de eventos se ejecutan en orden arbitrario. Aunque Ethereum actualmente no tiene paralelismo a la hora de ejecutar smart contracts, a este tipo de ataques se les denomina condiciones de carrera porque se produce cuando hay procesos lógicamente distintos que luchan por los mismo recursos, y por lo tanto encontramos los mismos tipos de problemas y posibles soluciones que en los casos de concurrencia clásicos.

Uno de los principales peligros de llamar a contratos externos es que éstos pueden tomar el control sobre el flujo y realizar cambios en los datos que la función de llamada no esperaba. Esta clase de error puede tomar muchas formas, y los dos errores principales que llevaron al problema de la DAO fueron errores de este tipo.

Reentrada

El primer tipo de condición de carrera que se encontró involucraba funciones que podían ser llamadas repetidamente antes de que la primera invocación de la función hubiera finalizado.

// ATENCIÓN: CÓDIGO NO SEGURO
mapping (address => uint) private userBalances;

function withdrawBalance() public {
    uint amountToWithdraw = userBalances[msg.sender];
    require(msg.sender.call.value(amountToWithdraw)()); 
    userBalances[msg.sender] = 0;
}

En este caso en concreto, la función a la que llama msg.sender.call.value (amountToWithdraw) en un contrato externo podría volverá llamar a withdrawBalance de forma recursiva. Dado que el saldo del usuario no se establece a 0 hasta el final de la función, la segunda (y posterior) invocaciones seguirá teniendo éxito, y retirará el saldo una y otra vez. Un error muy similar fue una de las vulnerabilidades en el ataque DAO.

En el ejemplo dado, la mejor manera de evitar el problema es usar send en lugar de call.value.

// ATENCIÓN: CÓDIGO NO SEGURO
mapping (address => uint) private userBalances;

function withdrawBalance() public {
    uint amountToWithdraw = userBalances[msg.sender];
    if(msg.sender.send(amountToWithdraw)) 
        userBalances[msg.sender] = 0;
}

Ésto evitará que se ejecute cualquier código externo.

Sin embargo, si no se pudiese eliminar la llamada externa, la siguiente forma más sencilla de evitar este ataque es asegurarse de no llamar a una función externa hasta que haya realizado todo el trabajo interno que necesita hacer:

mapping (address => uint) private userBalances;

function withdrawBalance() public {
    uint amountToWithdraw = userBalances[msg.sender];
    userBalances[msg.sender] = 0;
    require(msg.sender.call.value(amountToWithdraw)()); 
}

En este caso, el saldo del usuario ya es 0 cuando se llama a la función externa, evitando que pueda sacar el saldo múltiples veces.

Condiciones de carrera multifuncionales

Un atacante también puede realizar un ataque similar utilizando dos funciones diferentes que comparten el mismo estado.

// ATENCIÓN: CÓDIGO NO SEGURO
mapping (address => uint) private userBalances;

function transfer(address to, uint amount) {
    if (userBalances[msg.sender] >= amount) {
       userBalances[to] += amount;
       userBalances[msg.sender] -= amount;
    }
}

function withdrawBalance() public {
    uint amountToWithdraw = userBalances[msg.sender];
    require(msg.sender.call.value(amountToWithdraw)()); 
    userBalances[msg.sender] = 0;
}

En este caso, el atacante podría llamar a transfer cuando su código se ejecuta en la llamada externa en withdrawBalance. Dado que su saldo aún no se ha establecido en 0, pueden transferir los tokens aunque ya se haya realizado la retirada del saldo. Esta vulnerabilidad también se usó en el ataque DAO.

En este ejemplo, ambas funciones fueron parte del mismo contrato. Sin embargo, el mismo error puede ocurrir en múltiples contratos, si esos contratos comparten estado.

Problemas en las soluciones de condiciones de carrera

Dado que las condiciones de carrera pueden ocurrir en múltiples funciones, e incluso en múltiples contratos, cualquier solución destinada a prevenir la reentrada no será suficiente.

La recomendación es siempre terminar primero todo el trabajo interno, y solo luego llamar a la función externa. Esta regla, si se sigue con cuidado, te permitirá evitar las condiciones de carrera. Sin embargo, no solo debe evitar llamar a funciones externas demasiado pronto, sino también evitar llamar a funciones que invocan funciones externas. Por ejemplo, lo siguiente es inseguro:

// ATENCIÓN: CÓDIGO NO SEGURO
mapping (address => uint) private userBalances;
mapping (address => bool) private claimedBonus;
mapping (address => uint) private rewardsForA;

function withdraw(address recipient) public {
    uint amountToWithdraw = userBalances[recipient];
    rewardsForA[recipient] = 0;
    require(recipient.call.value(amountToWithdraw)());
}

function getFirstWithdrawalBonus(address recipient) public {
    require(!claimedBonus[recipient]); // cada destinatario debería recibir el bonus solo una vez

    rewardsForA[recipient] += 100;
    withdraw(recipient);
    claimedBonus[recipient] = true;
}

Dado que untrustedGetFirstWithdrawalBonus llama a untrustedWithdraw, que llama a un contrato externo, también debe considerar inseguro a untrustedGetFirstWithdrawalBonus.

Otra solución que a menudo se sugiere es usar mecanismos de exclusión mutua mediante mutexes. Esto permite “bloquear” algún estado para que solo pueda ser modificado por el propietario del bloqueo. La sección crítica es el fragmento de código donde puede modificarse un recurso compartido. Por ejemplo:

mapping (address => uint) private balances;
bool private lockBalances; //mutex

function deposit() payable public returns (bool) {
    require(!lockBalances);
    lockBalances = true; // bloqueamos el mutex
    balances[msg.sender] += msg.value;
    lockBalances = false; // liberamos el mutex
    return true;
}

function withdraw(uint amount) payable public returns (bool) {
    require(!lockBalances && amount > 0 && balances[msg.sender] >= amount);
    lockBalances = true;

    if (msg.sender.call(amount)()) { // Ésto sería inseguro pero queda resuelto con el mutex
      balances[msg.sender] -= amount;
    }

    lockBalances = false;
    return true;
}

Si el usuario intenta volver a llamar a withdraw antes de que finalice la primera llamada, el bloqueo evitará que tenga algún efecto. Éste puede ser un patrón efectivo, pero se vuelve complicado cuando se tiene múltiples contratos trabajando conjuntamente. El siguiente código no es seguro:

// ATENCIÓN: CÓDIGO NO SEGURO
contract StateHolder {
    uint private n;
    address private lockHolder;

    function getLock() {
        require(lockHolder == 0);
        lockHolder = msg.sender;
    }

    function releaseLock() {
        require(msg.sender == lockHolder);
        lockHolder = 0;
    }

    function set(uint newState) {
        require(msg.sender == lockHolder);
        n = newState;
    }
}

Un atacante puede llamar a getLock y no a llamar a releaseLock posteriormente. De producirse, el contrato quedará bloqueado para siempre y no se podrán realizar más cambios. Si usas mutexes para protegerte de las condiciones de carrera, deberás asegurarse de que no haya ninguna forma de reclamar y nunca liberar un bloqueo.

Con ésto hemos cubierto los ataques de condiciones de carrera. En el próximo artículo veremos los ataques y errores por desbordamiento de números enteros.

Espero que os haya gustado y ¡hasta el próximo artículo!

Próximo artículo: Ataques a smart contracts y cómo defenderse (II): Desbordamiento de números enteros

Anuncios

Compártelo:

Me gusta:

Me gusta

Cargando…

Hayden P.

A blockchain and cryptocurrency enthusiast

You may also like...