Site icon WSJ-Crypto

Ensuring Security in Smart Contracts: Insights from the Ethereum Foundation

Solidity was initiated in October 2014 when neither the Ethereum network nor the virtual machine had undergone any practical testing; the gas fees at that point were even tremendously distinct from what they are today. In addition, several of the initial design choices were inherited from Serpent. Over the past few months, instances and protocols that were previously deemed best practice have been subjected to real-world scenarios, and some of them indeed proved to be counterproductive patterns. Consequently, we have recently revised portions of the Solidity documentation, but since it is likely that many individuals do not keep up with the flow of github updates to that repository, I am keen to emphasize some of the discoveries here.

I will refrain from discussing the minor concerns here; for more information, please refer to the documentation.

Sending Ether

Transferring Ether is believed to be one of the most straightforward tasks in Solidity, yet it appears to possess various nuances that most individuals are unaware of.

It is crucial that ideally, the recipient of the ether triggers the payment. The following presents a POOR illustration of an auction contract:

// THIS IS A NEGATIVE ILLUSTRATION! DO NOT UTILIZE!
contract auction {
  address highestBidder;
  uint highestBid;
  function bid() {
    if (msg.value  highestBid) throw;
    if (highestBidder != 0)
      highestBidder.send(highestBid); // refund previous bidder
    highestBidder = msg.sender;
    highestBid = msg.value;
  }
}

Due to the maximal stack depth of 1024, the new bidder can consistently escalate the stack size to 1023 and subsequently invoke bid() which will lead to the send(highestBid) call failing silently (meaning, the former bidder will not receive the reimbursement), yet the new bidder remains the highest bidder. One method to verify if send was successful is to evaluate its return value:

/// THIS IS STILL A NEGATIVE ILLUSTRATION! DO NOT UTILIZE!
if (highestBidder != 0)
  if (!highestBidder.send(highestBid```html
))
    throw;

The

throw

statement triggers a rollback of the ongoing invocation. This is unfavorable as the recipient, for instance, by establishing the fallback function as

function() { throw; }

can always compel the Ether transaction to fail, which results in preventing anyone from outbidding her.

The sole method to avert both scenarios is to change the sending pattern into a withdrawal pattern by granting the recipient authority over the transfer:

/// THIS IS STILL A NEGATIVE EXAMPLE! DO NOT USE!
contract auction {
  address highestBidder;
  uint highestBid;
  mapping(address => uint) refunds;
  function bid() {
    if (msg.value  highestBid) throw;
    if (highestBidder != 0)
      refunds[highestBidder] += highestBid;
    highestBidder = msg.sender;
    highestBid = msg.value;
  }
  function withdrawRefund() {
    if (msg.sender.send(refunds
``````html
[msg.sender]))
      reimbursements[msg.sender] = 0;
  }
}
 

Why does it still display “negative example” above the contract? Due to gas mechanics, the contract works fine, yet it remains a poor illustration. The reason lies in the impossibility of halting code execution at the recipient during a send operation. This implies that while the send function is still executing, the recipient may trigger a call back into withdrawRefund. At that moment, the refund amount remains unchanged, allowing them to receive the amount repeatedly. In this particular illustration, this scenario does not succeed, as the recipient only acquires the gas stipend (2100 gas), making it unfeasible to execute another send with this gas quantity. The following code, however, is susceptible to this vulnerability: msg.sender.call.value(reimbursements[msg.sender])().

Considering all these points, the following code should function correctly (albeit it still does not present a complete illustration of an auction contract):

contract auction {
  address topBidder;
  uint topBid;
  mapping(address => uint) reimbursements;
  function bid() {
    if (msg.value  topBid) throw;
    if (topBidder != 0)
      reimbursements[topBidder] += topBid;
    topBidder = msg.sender;
    topBid = msg.value;
  }
  function withdrawRefund() {
    uint reimbursement = reimbursements[msg.sender];
    reimbursements[msg.sender] = 0;
    if (!msg.sender.send(reimbursement))
     reimbursements
```[msg.sender] = refund;
  }
}

Take note that we did not utilize throw on a failed send since we can revert all state modifications manually, and avoiding throw results in significantly fewer side-effects.

Utilizing Throw

The throw command is frequently quite practical for reversing any alterations made to the state as part of the call (or entire transaction depending on how the function is invoked). Nevertheless, you must be cautious, as it will also exhaust all gas and is therefore costly and may hinder calls to the current function. For this reason, I suggest using it solely in the following scenarios:

1. Revert Ether transfer to the active function

If a function is not designed to accept Ether or is not in the correct state or with the appropriate parameters, you ought to use throw to decline the Ether. Employing throw is the only dependable method to return Ether due to gas and stack depth complications: The receiver might experience an issue in the fallback function that consumes too much gas and consequently cannot acquire the Ether, or the function might have been invoked in a malicious context with excessive stack depth (possibly even preceding the invoking function).

Bear in mind that unintentionally dispatching Ether to a contract is not invariably a failure in UX: You can never anticipate the order in which transactions are incorporated into a block. If the contract is structured to solely accept the initial transaction, the Ether contained in subsequent transactions must be refused.

2. Revert consequences of invoked functions

If you invoke functions on other contracts, it’s impossible to predict how they are constructed. This indicates that the repercussions of these calls are also unknown, thus the only method to negate these impacts is to use throw. Naturally, you should always architect your contract to avoid these functions in the first place if you’re aware that you will need to nullify the effects, but there are specific use cases where you only ascertain that post-factum.

Loops and the Block Gas Limit

There is a restriction on how much gas can be expended in a single block. This cap is adaptable; however, it is rather challenging to increase it. Consequently, every function in your contract should remain below a specified gas limit in all (reasonable) circumstances. The following serves as a POOR illustration of a voting contract:

/// THIS IS STILL A NEGATIVE EXAMPLE! PLEASE DO NOT UTILIZE!
contract Voting {
  mapping(address => uint) voteWeight;
  address[] yesVotes;
  uint requiredWeight;
  address beneficiary;
  uint amount;
  function voteYes() { yesVotes.push(msg.sender); }
  function tallyVotes() {
    uint yesVotes;
    for (uint i = 0; i  yesVotes.length; ++i)
      yesVotes += voteWeight[yesVotes[i]];
    if (yesVotes > requiredWeight)
      beneficiary.send(amount);
  }
}

The contract indeed presents multiple issues, but the one that I aim to emphasize here involves the complication of the loop: Assume that vote weights are transferable and divisible like tokens (consider the DAO tokens as an illustration). This indicates that you can fabricate an unlimited number of replicas of yourself. Creating such replicas would extend the duration of the loop in the tallyVotes function until it consumes more gas than is permissible within a single block.

This is pertinent to anything that incorporates loops, including instances where loops are not overtly apparent in the contract, such as when you duplicate arrays or strings within storage. Once more, it is acceptable to possess arbitrary-length loops if the loop’s duration is governed by the caller, such as if you traverse an array that was submitted as a function parameter. However, never establish a condition where the loop duration is dictated by a party that would not solely endure the consequences of its malfunction.

As a brief note, this was one reason for the introduction of blocked accounts within the DAO contract: Vote weight is determined at the moment the vote is made, to avert the situation where the loop could get ensnared, and if the vote weight remained variable until the conclusion of the voting timeframe, it would allow you to cast a second vote simply by transferring your tokens and then voting once more.

Receiving Ether / the fallback function

If you wish for your contract to accept Ether through the standard send() call, it is essential to ensure that its fallback function is inexpensive. It can only utilize 2300 gas, which does not permit any storage modifications or function calls that include Ether. In essence, the primary action you should execute within the fallback function is to log an event so that external processes can respond accordingly. Naturally, any contract function can accept ether and is not constrained by that gas limitation. Functions need to actively reject Ether sent to them if they do not intend to accept any, but we are contemplating possibly reversing this behavior in a future update.



Source link

Exit mobile version