How to Avoid Reentrancy Attacks on your Concordium smart contracts — Part 1

Concordium
December 5, 2023

When developing smart contracts, security is a major concern since any exploit will result in lost trust. It is important for any smart contract developer that tools and features exist which can ensure the quality of the contract written.

Concordium has security as one of the highest concerns. Contracts are written in Rust which is a safe and performant language. The Concordium standard library provides safe functions for writing smart contracts, including ones for mitigating a common security exploit known as “reentrance attacks”.

Concordium also has an integration test framework where you can easily test contract-to-contract interactions.

This article is in two parts. The first one focuses on reentrance attacks and how to avoid them on Concordium. The second focuses on the Concordium integration test framework, a powerful tool for writing automated tests in Rust for contract interactions.

Reentrance attacks

Reentrance attacks are where one contract, an attacker, unexpectedly calls a victim contract. If the victim contract hasn’t been written correctly, the attacking contract can exploit vulnerabilities by e.g., stealing tokens or native coins.

The simple sequence diagram below describes the scenario — an attacker keeps calling the victim’s entrypoint `withdraw` in which there is a reentrant exploit. Part of the function `withdraw` is to make a call to an entrypoint on the attacking contract, in this case `receive`.

The call is needed because contracts can only update their own state and the receiver contract might need to make changes following the receival of funds.

The attacker keeps making a call to the entrypoint `withdraw` on the victim address until some condition is satisfied. Such a condition could be validation of an amount of either tokens or coins on the victim contract so it is enough to make yet another successful invocation such that the whole transaction wouldn’t fail.

Reentrance attacks aren’t new and if you have experience writing e.g. Solidity contracts you may be familiar with mitigations like:

  • Use the checks-effects-interactions pattern
    Entrypoints start with validating inputs, then apply effects, and lastly makes any interactions with other contracts or accounts.
  • Mutex

In this case the contract acquires a lock which makes the entrypoint non-reentrant.

Now let’s dive into reentrance on Concordium smart contracts.

Reentrant smart contract

This is not a post about smart contracts in general, so I will only show and explain relevant parts from contracts. All contracts covered can be found on github. To learn more about general smart contract development on Concordium there are lots of nice tutorials and documentation you can visit and explore.

Let’s start by making a contract which can be exploited.

#[derive(Debug, Serialize, Clone, SchemaType)]pub enum Receiver {    Account(AccountAddress),    Contract(ContractAddress, OwnedEntrypointName),}#[receive( contract = "reentrance", name = "deposit", parameter = "()", mutable, payable)]fn contract_deposit( ctx: &ReceiveContext, host: &mut Host<State>, amount: Amount,) -> Result<(), Error> { ...}#[receive(    contract = "reentrance",    name = "withdraw",    parameter = "Receiver",    error = "Error",    mutable)]fn contract_withdraw(ctx: &ReceiveContext, host: &mut Host<State>) -> Result<(), Error> {    let params: Receiver = ctx.parameter_cursor().get()?;    let state = host.state();    let address = params.get_address();    let deposited = state        .balances        .get(&address)        .ok_or(Error::NothingDeposited)?;    let amount_to_transfer = deposited.to_owned();    match params {        Receiver::Account(address) => host.invoke_transfer(&address, amount_to_transfer)?,        Receiver::Contract(address, function) => {            host.invoke_contract_raw(&address,                Parameter::empty(),                function.as_entrypoint_name(),                amount_to_transfer,            )?;        }    };    host.state_mut().balances.remove(&address);    Ok(())}

The contract has an entrypoint `deposit` where addresses (either contract or account) are depositing CCDs to the contract. The depositor is then able to withdraw their deposited amount from the contract balance using the entrypoint `withdraw`.

Now the exploit is in the `withdraw` entrypoint in these lines

match params.receiver {        Receiver::Account(address) => host.invoke_transfer(&address, amount_to_transfer)?,        Receiver::Contract(address, function) => {            host.invoke_contract_raw(&address,                Parameter::empty(),                function.as_entrypoint_name(),                amount_to_transfer,            )?;        }    };    host.state_mut().balances.remove(&address);

The victim contract, `reentrance`, transfers and invokes other contracts before updating its internal state of balances. Due to this, an attacker would be able to keep calling this entrypoint, and each time the victim contract would keep seeing a state which says there is a deposited amount and keep transferring CCDs.

Let’s make an attacking contract and see the logic which can exploit this.

#[receive(    contract = "attacker",    name = "attack",    parameter = "()",    error = "Error",    enable_logger,    mutable)]fn contract_attacker_attack(    ctx: &ReceiveContext,    host: &mut Host<AttackerState>,    logger: &mut Logger,) -> Result<(), Error> {    ensure!(ctx.sender().matches_account(&ctx.owner()), Error::Owner);    let victim = host.state().victim;    let params = Receiver::Contract(        ctx.self_address(),        OwnedEntrypointName::new_unchecked("receive".to_string()),    );    host.invoke_contract(&victim,&params,        EntrypointName::new_unchecked("withdraw"),        Amount::zero(),    )?;    logger.log(&AttackerEvent::Exploited(victim, host.self_balance()))?;    host.state_mut().deposit = Amount::zero();    Ok(())}#[receive(    contract = "attacker",    name = "receive",    parameter = "()",    error = "Error",    mutable,    payable)]fn contract_attacker_receive(    ctx: &ReceiveContext,    host: &mut Host<AttackerState>,    _amount: Amount,) -> Result<(), Error> {    ensure!(        ctx.sender().matches_contract(&host.state().victim),        Error::WrongVictimAddress    );    let victim = host.state().victim;    let victim_balance = host.contract_balance(victim)?;    // Check whether we can attack more times.    if victim_balance >= host.state().deposit {        let params = Receiver::Contract(            ctx.self_address(),            // Let the callback go to the "receive" entrypoint            OwnedEntrypointName::new_unchecked("receive".to_string()),        );        host.invoke_contract_raw(&victim,            Parameter::new_unchecked(&to_bytes(&params)),            EntrypointName::new_unchecked("withdraw"),            Amount::zero(),        )?;    }    Ok(())}

The attacker has an entrypoint `attack` from where it initiates the attack. When calling `withdraw` on the victim contract, the attacker gives as parameter an entrypoint to which the victim should make a callback and transfer CCDs — in this case the attacker wants the victim to call the entrypoint `receive` on the attacking contract.

When the entrypoint `receive` is called from the victim contract, the attacker then first checks the balance of the victim contract.

let victim = host.state().victim;    let victim_balance = host.contract_balance(victim)?;    if victim_balance >= host.state().deposit {        // continue calling victim's `withdraw`    }

If this wasn’t checked the whole attack would fail. If the victim just kept sending CCDs, then the victim contract would in the end have an insufficient amount to do a transfer and the whole transaction will fail.

Now we know what not to do — let’s dive into fixing our contract.

Mitigations

To avoid a reentrance attack you can:

  • Follow the checks-effects-interactions pattern
    The same pattern as mentioned when writing Solidity contracts.
  • Use the Concordium feature `​​invoke_contract_raw_read_only`/`​​invoke_contract_read_only` when calling other contracts. By using these methods you don’t allow contract state changes.
  • Use a mutex
    A mutex solution with multiple entrypoints should be designed carefully, so the contract does not end up being locked up. So, in general, it’s not a recommended way of preventing reentrant calls on Concordium.

Checks-effects-interactions

To follow the checks-effect-interactions pattern you just need to move the order of code execution.

let deposited = state    .balances    .get(&address)    .ok_or(Error::NothingDeposited)?;let amount_to_transfer = deposited.to_owned();// effects now applied before interactionshost.state_mut().balances.remove(&address);match params {    Receiver::Account(address) => host.invoke_transfer(&address, amount_to_transfer)?,    Receiver::Contract(address, function) => {        host.invoke_contract_raw(&address,            Parameter::empty(),            function.as_entrypoint_name(),            amount_to_transfer,        )?;    }};

You now remove the callee address from the balance state (the effect) before you invoke the entrypoint of the callee contract (the interaction). Because of this the contract will fail in the check for any deposited amount if the contract’s entrypoint was reentered.

This solution does not work if the result of the contract call is needed to modify the state.

`invoke_contract_raw_read`/`invoke_contract_read`

This is a Concordium feature and the mitigation is to change the method you use when invoking the entrypoint on the callee contract. Because of this, contract state changes within the function call will make the transaction fail.

match params {    Receiver::Account(address) => host.invoke_transfer(&address, amount_to_transfer)?,    Receiver::Contract(address, function) => {        host.invoke_contract_raw_read_only(&address,            Parameter::empty(),            function.as_entrypoint_name(),            amount_to_transfer,        )?;    }};

This solution can be used in any situation, including situations where moving the call to the end of the entrypoint is not possible (that is, if one wants to call a contract and use the return data to mutate the state).

Mutex

To use a lock you need to have a mutex in your contract state

pub struct State<S = StateApi> {balances: StateMap<Address, Amount, S>, lock: bool,}

Now you would be able to wrap the interaction part within this mutex and a check which validates that the lock hasn’t been acquired.

ensure!(!host.state().lock, Error::Lock);...host.state_mut().lock = true;match params {    Receiver::Account(address) => host.invoke_transfer(&address, amount_to_transfer)?,    Receiver::Contract(address, function) => {        host.invoke_contract_raw_read_only(&address,            Parameter::empty(),            function.as_entrypoint_name(),            amount_to_transfer,        )?;    }};host.state_mut().lock = false;

Conclusion

We have reached the end of the article and we have explained reentrance attacks in detail. We have dived into an example of a Concordium smart contract which could be exploited and afterwards shown how reentrancy attacks can be mitigated. The takeaway is that avoiding reentrance attacks on Concordium is easy and Concordium has built in features which ensure that you don’t get surprised by side effects.

How can we test this? One way would be to deploy to a test environment but we can do better; with automated programmatic tests. Luckily, the Concordium team comes to the rescue with an awesome integration test framework. Dive into the second part to see how we can make tests which validates both the attack and mitigations.

References

Crypto
Developers
Technology