Solidity contract security, common vulnerabilities (Part 2)

Solidity contract security, common vulnerabilities (Part 2)

Solidity contract security, common vulnerabilities (Part 1)

insecure random number

It is currently not possible to securely generate random numbers with a single transaction on the blockchain. Blockchains need to be fully deterministic, otherwise the distributed nodes will not be able to reach consensus about the state. Because they are completely deterministic, any "random" number can be predicted. The following dice roll function can be utilized.

contract UnsafeDice {
    function randomness() internal returns (uint256) {
        return keccak256(abi.encode(msg.sender, tx.origin, block.timestamp, tx.gasprice, blockhash(block.number - 1);
    }

    // our dice can land on one of {0,1,2,3,4,5}function rollDice() public payable {
        require(msg.value == 1 ether);

        if (randomness() % 6) == 5) {
            msg.sender.call{value: 2 ether}("");
        }
    }
}

contract ExploitDice {
    function randomness() internal returns (uint256) {
        return keccak256(abi.encode(msg.sender, tx.origin, block.timestamp, tx.gasprice, blockhash(block.number - 1);
    }

    function betSafely(IUnsafeDice game) public payable {
        if (randomness % 6) == 5)) {
            game.betSafely{value: 1 ether}()
        }

        // else don't do anything
    }
}

It doesn't matter how the random number is generated, since an attacker can replicate it exactly. Using more sources of "entropy" like msg.sender, timestamp, etc. will not have any effect, since the smart contract can predict it too.

Misuse of Chainlink nonce oracles

Chainlink is a popular solution to obtain secure random numbers. It works in two steps. First, the smart contract sends a request for a nonce to the oracle, and then after some blocks, the oracle responds with a nonce.
Since attackers cannot predict the future, they cannot predict nonces.
Unless the smart contract uses the oracle incorrectly:

  • A smart contract requesting a nonce must do nothing until the nonce is returned. Otherwise, an attacker could monitor the mempool for the oracle that returned the nonce, and run the oracle in front of it, knowing what the nonce would be.
  • The random number oracle itself may try to manipulate your application. They can't pick a nonce without consensus from other nodes, but they can withhold and reorder if your application requests several nonces at the same time.
  • Finality is not instant on Ethereum or most other EVM chains. Just because some block is up to date doesn't mean it won't necessarily stay that way. This is called an "on-chain reorganization". In fact, the chain can change more than just the last block. This is known as "deep restructuring". Etherscan reports re-orgs of various chains, such as the Ethereum reorganization and the Polygon reorganization. On Polygon, reorganizations can be as deep as 30 blocks or more, so waiting for fewer blocks makes applications vulnerable (this may change when zk-evm becomes standard consensus on Polygon , because finality will be consistent with Ethereum's, but this is a future prediction, not a current fact).
  • The following are security considerations for other Chainlink nonces.

Get stale data from price oracle

Chainlink has no SLA (service level agreement) to keep its price oracles updated within a certain time frame. When transactions on the chain are heavily congested, price updates may be delayed.
Smart contracts that use price oracles must explicitly check whether data is stale, i.e. was recently updated within a certain threshold. Otherwise, it cannot make reliable decisions about price.
There is also a further complication, if the price has not changed beyond the deviation threshold , the oracle may not update the price to save gas, so this may affect what time threshold is considered "stale".
It is important to understand the service level agreement of the oracle on which the smart contract depends.

Only rely on one oracle

No matter how secure an oracle appears to be, attacks may be detected in the future. The only defense against this is to use multiple independent oracles.

In general, oracles are hard to get right

Blockchains can be quite secure, but putting data on-chain in the first place requires some kind of off-chain operation, which gives up all the security guarantees that blockchains provide. Even if oracles remain honest, their data sources can be manipulated. For example, a messenger can reliably report prices from a centralized exchange, but those prices can be manipulated by floods of buy and sell orders. Likewise, oracles that rely on sensor data or some web2 APIs are subject to traditional hacking.
A good smart contract architecture avoids oracles altogether when possible.

hybrid computing

Consider the following contract

contract MixedAccounting {
  uint256 myBalance;

  function deposit() public payable {
    myBalance = myBalance + msg.value;
  }

  function myBalanceIntrospect() public view returns (uint256) {
    return address(this).balance;
  }

  function myBalanceVariable() public view returns (uint256) {
    return myBalance;
  }

  function notAlwaysTrue() public view returns (bool) {
    return myBalanceIntrospect() == myBalanceVariable();
  }
}

The contract above has no receive or fallback functions, so sending ether directly to it will fallback. However, a contract can forcefully send ether to it in a self-destructing manner. In this case, myBalanceIntrospect() will be larger than myBalanceVariable(). There is no problem with either method of calculating ether, but if you use both methods at the same time, the contract may have inconsistent behavior.
The same applies to ERC20 tokens.

contract MixedAccountingERC20 {

  IERC20 token;
  uint256 myTokenBalance;

  function deposit(uint256 amount) public {
    token.transferFrom(msg.sender, address(this), amount);
    myTokenBalance = myTokenBalance + amount;
  }

  function myBalanceIntrospect() public view returns (uint256) {
    return token.balanceOf(address(this));
  }

  function myBalanceVariable() public view returns (uint256) {
    return myTokenBalance;
  }

  function notAlwaysTrue() public view returns (bool) {
    return myBalanceIntrospect() == myBalanceVariable();
  }
}

Again we cannot assume that myBalanceIntrospect() and myBalanceVariable() will always return the same value. It is possible to transfer ERC20 tokens directly to MixedAccountingERC20, bypassing the deposit function and not updating the myTokenBalance variable.
When checking balances with introspection, strict equality checks should be avoided, since balances can be changed at will by outsiders.

Treat cryptographic proofs like passwords

This isn't a quirk of Solidity, more of a general misunderstanding among developers of how to use cryptography to give addresses special permissions. The following code is unsafe:

contract InsecureMerkleRoot {
  bytes32 merkleRoot;
  function airdrop(bytes[] calldata proof, bytes32 leaf) external {

    require(MerkleProof.verifyCalldata(proof, merkleRoot, leaf), "not verified");
    require(!alreadyClaimed[leaf], "already claimed airdrop");
    alreadyClaimed[leaf] = true;

    mint(msg.sender, AIRDROP_AMOUNT);
  }
}

This code is unsafe for three reasons:

  1. Anyone who knows the address that was chosen for the airdrop can recreate the Merkle tree and create a valid proof.
  2. Leaves do not have a Hash. An attacker can submit a leaf that is the same as the Merkle root and bypass the require statement.
  3. Even if the above two issues are fixed, once someone submits a valid proof, they can be snapped up.

Cryptographic proofs (Merkle trees, signatures, etc.) need to be tied to msg.sender, which cannot be manipulated by an attacker without obtaining the private key.

Solidity will not upcast uint size

function limitedMultiply(uint8 a, uint8 b) public pure returns (uint256 product) {
  product = a * b;
}

Although product is a uint256 variable, the result of the multiplication cannot be greater than 255, otherwise the code will be rolled back.
This problem can be solved by upcasting each variable:

function unlimitedMultiply(uint8 a, uint8 b) public pure returns (uint256 product) {
  product = uint256(a) * uint256(b);
}

This also happens when multiplying integers in structures. You should be aware of this when multiplying decimal values ​​in structures:

struct Packed {
  uint8 time;
  uint16 rewardRate
}

//...

Packed p;
p.time * p.rewardRate; // this might revert!

Solidity truncation does not fall back

Solidity does not check whether it is safe to convert an integer to a smaller integer. Unless some business logic ensures that the downcast is safe, a library like SafeCast should be used.

function test(int256 value) public pure returns (int8) {
  return int8(value + 1); // overflows and does not revert
}

Writes to storage pointers do not save new data

This code looks like it copies the data from myArray[1] to myArray[0], but it doesn't. If you comment out the last line of the function, the compiler will say that the function should be turned into a view function. Writes to foo are not written to the underlying storage.

contract DoesNotWrite {
  struct Foo {
    uint256 bar;
  }
  Foo[] public myArray;

  function moveToSlot0() external {
    Foo storage foo = myArray[0];
    foo = myArray[1]; // myArray[0] 不会改变
    // we do this to make the function a state
    // changing operation
    // and silence the compiler warning
    myArray[1] = Foo({bar: 100});
  }
}

So don't write to the storage pointer.

Deleting a struct containing dynamic data types does not delete the dynamic data

If a map (or dynamic array) is inside a struct, and that struct is deleted, then the map or array will not be deleted.
Except for deleting an array, the delete keyword can only delete a storage slot. If the slot contains references to other slots, those slots are not deleted.

contract NestedDelete {

  mapping(uint256 => Foo) buzz;

  struct Foo {
    mapping(uint256 => uint256) bar;
  }

  Foo foo;

  function addToFoo(uint256 i) external {
    buzz[i].bar[5] = 6;
  }

  function getFromFoo(uint256 i) external view returns (uint256) {
    return buzz[i].bar[5];
  }

  function deleteFoo(uint256 i) external {
    // internal map still holds the data in the
    // mapping and array
    delete buzz[i];
  }
}

Now let's do the following sequence of transactions

  1. addToFoo(1)
  2. getFromFoo(1) returns 6
  3. deleteFoo(1)
  4. getFromFoo(1) still returns 6!

Remember, in Solidity, a map is never "empty". Therefore, if someone accesses an item that has already been deleted, the transaction will not roll back, but return a zero value for that data type.

Guess you like

Origin blog.csdn.net/weixin_28733483/article/details/132466173