Solidity Contract Security, Common Vulnerabilities (Part 3)

Solidity Contract Security, Common Vulnerabilities (Part 3)

ERC20 Token Issues

Most of these questions don't apply if you're only dealing with trusted ERC20 tokens. However, there are some caveats when interacting with arbitrary or partially untrusted ERC20 tokens.

ERC20: transfer fee deduction

When dealing with untrusted coins, you should not assume that your balance will necessarily increase that much. It is possible for an ERC20 token to implement its transfer function as follows:

contract ERC20 {

  // internally called by transfer() and transferFrom()
  // balance and approval checks happen in the caller
  function _transfer(address from, address to, uint256 amount) internal returns (bool) {
    fee = amount * 100 / 99;

    balanceOf[from] -= to;
    balanceOf[to] += (amount - fee);

    balanceOf[TREASURY] += fee;

    emit Transfer(msg.sender, to, (amount - fee));
    return true;
  }
}

The token imposes a 1% tax on every transaction. Therefore, if a smart contract interacts with this token as follows, we will get unexpected fallbacks or asset theft.

contract Stake {

  mapping(address => uint256) public balancesInContract;

  function stake(uint256 amount) public {
    token.transferFrom(msg.sender, address(this), amount);
    balancesInContract[msg.sender] += amount; //  这是错误的
  }

  function unstake() public {
    uint256 toSend = balancesInContract[msg.sender];
    delete balancesInContract[msg.sender];

    // this could revert because toSend is 1% greater than
    // the amount in the contract. Otherwise, 1% will be "stolen"// from other depositors.
    token.transfer(msg.sender, toSend);
  }
}

ERC20: Token for rebase

Rebasing tokens are promoted by Olympus DAO 's sOhm token and Ampleforth 's AMPL token. Coingecko maintains a list of Rebasing ERC20 tokens .
When a token is retraced, the total issuance changes and everyone's balance increases or decreases depending on the direction of the retrace.
The following code may break when processing rebase tokens:

contract WillBreak {
  mapping(address => uint256) public balanceHeld;
  IERC20 private rebasingToken

  function deposit(uint256 amount) external {
    balanceHeld[msg.sender] = amount;
    rebasingToken.transferFrom(msg.sender, address(this), amount);
  }

  function withdraw() external {
    amount = balanceHeld[msg.sender];
    delete balanceHeld[msg.sender];

    // 错误, amount 也许会超出转出范围
    rebasingToken.transfer(msg.sender, amount);
  }
}

The solution for many contracts is to simply not allow rebase tokens. However, we can modify the code above to check balanceOf(address(this)) before transferring the account balance to the recipient. Well, it will still work even if the balance changes.

ERC20: Parcel of ERC777 on ERC20

ERC20, if implemented according to the standard, ERC20 tokens do not have a transfer hook (hook), so transfer and transferFrom will not have re-entry problems.
Tokens with transfer hooks have application advantages, which is why all NFT standards implement them, and why ERC777 was finalized. However, this has caused enough confusion that Openzeppelin has deprecated the ERC777 library.
If you just want your protocol to be compatible with tokens that behave like ERC20 tokens but have a transfer hook, then it's a simple matter of thinking of the transfer and transferFrom functions as if they would make a function call to the receiver .
This ERC777 reentrancy happened with Uniswap (Openzeppelin has documented this bug here if you're curious).

ERC20: Not all ERC20 token transfers will return true

The ERC20 specification stipulates that ERC20 tokens must return true when the transfer is successful. Since most ERC20 implementations are unlikely to fail unless authorization is insufficient or the amount transferred is too large, most developers have become accustomed to ignoring the return value of ERC20 tokens and assuming that a failed transfer will be rolled back.
Frankly, it doesn't really matter if you're only dealing with one trusted ERC20 token whose behavior you know. But this difference in behavior must be taken into account when dealing with arbitrary ERC20 tokens.
There is an implicit expectation in many contracts that failed transfers should always fall back, rather than return an error, and since most ERC20 tokens have no mechanism for returning an error, this leads to a lot of confusion.
Compounding this problem is that some ERC20 tokens do not follow the protocol of returning true, notably Tether. Some tokens will be rolled back after the transfer fails, which will cause the result of the rollback to bubble up to the caller. Therefore, some libraries wrap ERC20 token transfer calls to fallback and return a boolean. Here are some implementation methods:
Reference: Openzeppelin SafeTransfer and Solady SafeTransfer (greatly improved Gas efficiency)

ERC20: Address Poisoning

This is not a smart contract bug, but we mention it here for completeness.
Transferring zero tokens is allowed by the ERC20 specification. This can lead to confusion in front-end applications and can trick users into thinking they recently sent tokens to an address. Metamask has more on this issue in this thread .

ERC20: Check the code and avoid running away

(In web3 terminology, "rugged" means ''run away'', which literally translates to "pull out the rug from under your feet".) There's nothing stopping
someone from adding functions to ERC20 tokens that let them create, transfer, and destroy at will Tokens – either self-destruct or upgrade. So basically there is a limit to how “trustless” an ERC20 token can be.

Logical errors in lending agreements

When considering how lending based on DeFi protocols can be disrupted, it is helpful to consider bugs that propagate at the software level and affect the business logic level. There are many steps to forming and completing a bond contract. Here are some attack vectors to consider.

how lenders lose

  • A loophole that reduces the principal due (possibly to zero) without making any payment.
  • When the loan is not repaid or the collateral falls below a threshold, the buyer's collateral cannot be liquidated.
  • This could be a way to steal bonds from lenders if the protocol has a mechanism for transferring ownership of the debt.
  • The due date of the loan principal or payment is inappropriately shifted to a later date.

how the borrower loses

  • A bug that did not reduce the principal debt when repaying the principal.
  • A bug or gas attack prevents users from making payments.
  • The principal or interest rate has been illegally increased.
  • The manipulation of the oracle leads to the devaluation of the collateral.
  • The due date of the loan principal or payment is inappropriately shifted to an earlier date.

If the collateral is taken away from the agreement, both the lender and the borrower lose because the borrower has no incentive to repay the loan, and the borrower loses the principal.
As you can see above, DeFi protocols have been "hacked" far more than a bunch of money has been siphoned from the protocol (the kind of event that usually makes the news).

Vulnerabilities in the staking protocol

The kind of hacks that make the news are staking agreements that get hacked for millions of dollars, but that's not the only problem that staking agreements can face are:

  • Can rewards be paid late, or claimed prematurely?
  • Can rewards be reduced or increased inappropriately? In the worse case, is it possible to prevent the user from getting any rewards?
  • Can people claim stakes or rewards that don't belong to them, and in the worst case, drain the protocol of all funds?
  • Will deposited assets get stuck in the protocol (partially or fully), or withdraw unduly delayed?
  • Conversely, if staking requires a time commitment, can users withdraw before the promised time?
  • If a different asset or currency is being paid, can its value be manipulated within the scope of the associated smart contract? This is relevant if the protocol mint own tokens to reward liquidity providers or stakers.
  • If there is an expected and disclosed risk factor for loss of principal, can this risk be unduly manipulated?
  • Are there management, centralization, or governance risks to key parameters of the protocol?

The key thing to look at is the part of the code that deals with the "funds exit" part.
There is also a "funding entry" loophole to look for.

  • Can users with the right to participate in staking assets in the protocol be inappropriately blocked?

Users receive rewards with an implied risk reward and an expected time value of money. It is helpful to be clear about what those assumptions are, and how the agreement might deviate from expectations.

unchecked return value

There are two ways to call an external smart contract: 1) use the interface definition to call the function; 2) use the .call method. As shown below:

contract A {
  uint256 public x;

  function setx(uint256 _x) external {
    require(_x > 10, "x must be bigger than 10");
    x = _x;
  }
}

interface IA {
  function setx(uint256 _x) external;
}

contract B {
  function setXV1(IA a, uint256 _x) external {
    a.setx(_x);
  }

  function setXV2(address a, uint256 _x) external {
    (bool success, ) =
    a.call(abi.encodeWithSignature("setx(uint256)", _x));
    // success is not checked!
  }
}

In contract B, if _x is less than 10, setXV2 will fail silently. When a function is called through the .call method, the callee can fall back, but the parent function will not fall back. A value that returns success must be checked, and code behavior must branch accordingly.

msg.value in a loop

It is dangerous to use msg.value in a loop, as it may allow the originator to reuse msg.value.
This situation may appear in payable multicalls. Multicalls enables users to submit a list of transactions to avoid double paying the 21,000 gas transaction fee. However, msg.value is "re-used" as it loops through the function, potentially double-spending the user.
This is the root cause of Opyn Hack .

private variable

Private variables are still visible on the blockchain, so sensitive information should not be stored there. If they cannot be accessed, how can validators process transactions that depend on their value? Private variables cannot be read from outside Solidity contracts, but they can be read off-chain using an Ethereum client.
To read a variable, you need to know its storage slot. In the example below, the storage slot for myPrivateVar is 0.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract PrivateVarExample {
  uint256 private myPrivateVar;

  constructor(uint256 _initialValue) {
    myPrivateVar = _initialValue;
  }
}

Below is the javascript code to read the private variables of the deployed smart contract

const Web3 = require("web3");
const PRIVATE_VAR_EXAMPLE_ADDRESS = "0x123..."; // Replace with your contract address

async function readPrivateVar() {
  const web3 = new Web3("http://localhost:8545"); // Replace with your provider's URL

  // Read storage slot 0 (where 'myPrivateVar' is stored)
  const storageSlot = 0;
  const privateVarValue = await web3.eth.getStorageAt(
    PRIVATE_VAR_EXAMPLE_ADDRESS,
    storageSlot
  );

  console.log("Value of private variable 'myPrivateVar':",
              web3.utils.hexToNumberString(privateVarValue));
}

readPrivateVar();

unsafe proxy call

Delegatecall should not be used for untrusted contracts as it gives all control to the delegate receiver. In this example, the untrusted contract stole all the ether in the contract.

contract UntrustedDelegateCall {
  constructor() payable {
    require(msg.value == 1 ether);
  }

  function doDelegateCall(address _delegate, bytes calldata data) public {
    (bool ok, ) = _delegate.delegatecall(data);
    require(ok, "delegatecall failed");
  }

}

contract StealEther {
  function steal() public {
    // you could also selfdestruct here
    // if you really wanted to be mean
    (bool ok,) =
    tx.origin.call{value: address(this).balance}("");
    require(ok);
  }

  function attack(address victim) public {
    UntrustedDelegateCall(victim).doDelegateCall(
      address(this),
      abi.encodeWithSignature("steal()"));
  }
}

Upgrade bugs related to proxy

We cannot do justice to this topic in one chapter. Most upgrade errors can usually be avoided by using the Openzeppelin hardhat plugin and reading the issues it protects.
As a quick summary, here are the issues related to smart contract upgrades:

  • Self-destruct and delegatecall should not be used in executing contracts.
  • It must be noted that during the upgrade process, stored variables cannot overwrite each other
  • Calls to external libraries should be avoided in executing contracts, as it is impossible to predict how they will affect storage access.
  • Deployers must not ignore calls to initialization functions
  • Gap variables are not included in the base contract to prevent storage collisions when adding new variables to the base contract (this is automatically handled by the hardhat plugin).
  • Values ​​in immutable variables are not preserved when upgrading
  • Doing anything in the constructor is highly discouraged, as future upgrades must execute the same constructor logic to maintain compatibility.

Guess you like

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