Smart Contract hackers have become a notorious threat to the rise of Decentralized Finance. In 2022 alone, there was about $2.7 billion worth of funds lost due to exploitation of the loopholes in smart contracts. This is approximately a 1250% increase from that in 2020!
These stats go to show how important proper security and auditing of your code is. Auditing is the process of reviewing your code to find potential bugs, ambiguity or severe flaws in your contracts. These are generally done by auditing firms, although solo auditing has become a new niche as well. However, here's something you should know:
Auditing does not mean your code is 100% 'not hackable'
Auditing only gives your contract a better chance to survive in the real world.
In this article, we'll go over a handful of some of the well-known attacks. However, many attacks with higher severity and occur frequently (we'll cover those soon).
Disclaimer: These attacks are not arranged in any order of severity
The Attacks👨🏻💻
Reentrancy Attack
Well obviously, we have to start with one of the biggest and baddest there is. The infamous "DAO Hack" was carried out using this very attack.
In June 2016, a DAO called "The DAO" was hacked and the attacker drained 3.6 million ETH which was equivalent to around $70 million at the time.
The attacker exploited a flaw in the code. He sent a small amount to the contract and then called the withdraw function recursively. This caused the contract to continuously send him ether from its balance. This is the basic premise of the Reentrancy Attack.
Vitalik Buterin, the founder of Ethereum suggested a soft fork in response to this attack. However, this led to the splitting of the Ethereum blockchain into- Ethereum(ETH) and Ethereum Classic(ETC).
The Solution?
For manual protection, developers may use a concept similar to Mutex Locks commonly used in Operating Systems to prevent deadlocks.
However, Open Zeppelin has a helpful function for tackling this attack and making your life easier. You can find more about the same here.
Honestly, this attack deserves its own show. So we will go over all the technical details and the internal workings of this attack in a later post.
Time Dependency Attack
This is more of manipulation than it is a hack(sort of). This sort of attack will happen if your contract uses block.timestamp
in any crucial calculation. block.timestamp
is a global variable provided by Solidity which returns the current time in Unix format. This variable can be accessed throughout your code.
uint256 constant private salt = block.timestamp;
function random(uint Max) constant private returns (uint256 result){
uint256 x = salt * 100/Max;
uint256 y = salt * block.number/(salt % 5) ;
uint256 seed = block.number/3 + (salt % 300) + Last_Payout + y;
uint256 h = uint256(block.blockhash(seed));
return uint256((h / x)) % Max + 1; //random number between 1 and Max
}
But here's the catch: validators and miners have control over this variable to some extent. They can manipulate the variable's value to produce favorable results. They may entirely change the output produced to ensure maximum profit. Hence, if block.timestamp
is used in a way that determines the flow of the program or results in any transaction, it can very easily be used to produce unforeseen results.
This may seem like a small and unimportant attack, but it can havoc if used correctly.
The solution?
According to the Ethereum Yellow Paper, each timestamp should be bigger than the timestamp of its parent. Thus, a good rule to follow while using timestamp is:
If the scale of your time-dependent event can vary by 15 seconds and maintain integrity, it is safe to use a
block.timestamp
.
The two popularly known Ethereum protocol implementations Geth and Parity reject any blocks with a timestamp greater than 15 seconds in the future.
Or, if you need to use block.timestamp
in a crucial part of your logic, consider using data from Decentralized Oracles instead (such as Chainlink).
Transaction Ordering Dependence Attack
TOD attack(also called Front Running attack) is generally carried out on Decentralized Exchanges(DEX). The Bancor DEX hack in 2020 which resulted in the loss of $460K worth of ether is an example of how dangerous this can be.
Transactions are not added immediately to a blockchain. They are collected into blocks and only added to the ledger as a part of these blocks. When a new block is being built, the block creator draws from a pool of unverified transactions. The order in which transactions are added to blocks is typically determined based on transaction fees. Higher the fees, faster your transaction will be confirmed.
The attacker makes use of this system to change the order of transactions in his favor. They submit their own transaction with higher fees to ensure it gets confirmed before the originally intended transactions.
This attack is highly effective in an exchange environment.
The Bancor Attack: The attack involved placing a large buy order for a certain token on the Bancor exchange and then using a second transaction to intentionally slow down the processing of the first transaction by including a very high gas price.
This caused the order to execute at a higher-than-market price, allowing the hackers to profit from the price difference. The hackers then used a third transaction to cancel the original buy order, allowing them to repeat the attack multiple times.
Tx.Origin Phishing Attack
Here's another example of an attack that makes use of a global variable provided by Solidity. First, let's go over how tx.origin
is different than msg.sender
:
tx.origin
is used to return the absolute calling parent. (can only be EOA)
msg.sender
is used to return the immediate parent. (can be EOA/CA)
Now, let us say there is a contract such as this:
contract myContract{
address public owner;
constructor(){
owner=msg.sender;
}
function withdraw(address payable recipient) public{
require(tx.origin==owner,"only owner can call");
recipient.transfer(address(this).balance);
}
}
The attacker will create their own contract which will have the basic functionalities such as this:
contract AttackContract {
myContract myContractRef;
address payable attacker;
constructor (myContract _myContractRef, address payable attackerAddress) {
myContractRef = _myContractRef;
attacker = _attackerAddress;
}
receive() external payable {
_myContractRef.withdrawAll(attacker);
}
}
Next step for the attacker is to send a phishing link to the owner of the contract. Once the owner clicks on the link and runs the transaction, it will call the withdraw
function from myContract
. Now here's where the magic happens:
In this case, tx.origin
will be the actual owner of the contract(since he/she called the function). But the recipient
variable will be set to the attacker's address. This means the entire contract balance will be sent to the attacker.
The Solution?
Well the only way to bypass this attack is reduce the usage of tx.origin
. Since this attack uses the basic architecture of Solidity, it becomes difficult to create a gateway to block it. A good principle to follow is:
Use msg.sender where you can. Use tx.origin only if absolutely necessary.
Overflow/Underflow
Overflow or Underflow attacks are quite simple to execute if certain checks are not put in place. This can easily cause the entire system to break resulting in unexpected results.
Here, again the attacker takes advantage of the architecture of Solidity. The fixed-size data types for integers are specified by the EVM which means it can represent only a certain range of numbers. Overflow/underflow happens when uint
are incremented/decremented past their limit. The max value for a uint
is 2 ^ 256 – 1. If an integer increments past that number, it will overflow, and the value will go back to 0. Similarly, if the value of a uint
falls below 0,it will underflow and circle back to the max value which is 1.157920892E77 — 1
.
//Overflow Example
function transfer(address _to, uint256 _amount) {
require(balanceOf[msg.sender] >= _amount);
balanceOf[msg.sender] -= _amount;
balanceOf[_to] += _amount;
}
For Overflow: The attacker can send a large amount of ether to the contract which exceeds the maximum value of a unit. If the value of the amount variable crosses the threshold of 2^256, then it is going to circle back and make it 0. This can lead to an unexpected result, such as the total balance becoming negative, and can enable the attacker to steal funds from the contract.
//Underflow Example
function withdraw(uint _amount) {
require(balances[msg.sender] – _amount > 0);
msg.sender.transfer(_amount);
balances[msg.sender] -= _amount;
}
For Underflow: Underflows are easier to achieve than their inverse. Here, there is no check for integer underflow, the attacker can withdraw large amounts of tokens. As a result, the attacker can withdraw more tokens than they own and may even get a maxed-out balance.
The Solution?
The easiest way to protect your code from Overflow/Underflow is to use a library provided by Open Zeppelin called Safe Math. Find out more on the same here.
DOS Attack
DOS or Denial of Service is a pretty common attack in the world of Cybersecurity. It is used to make servers completely useless by hogging up all their resources.
Similarly, DOS attacks on Smart contracts work by creating numerous redundant(but valid) transactions which block the success of authentic transactions.
Let's take a look at an example of an Auction contract as such:
contract Auction {
address prevBidder;
uint256 highestBid;
function bid() public payable {
require(msg.value > highestBid, "Need to be higher than highest bid");
// Refund the previous highest bidder, if it fails then revert
require(payable(prevBidder).send(highestBid), "Failed to send Ether");
prevBidder= msg.sender;
highestBid = msg.value;
}
}
And the attacker's contract:
import "./Auction.sol";
contract Attacker{
Auction auction;
constructor(Auction _auctionaddr){
auction = Auction(_auctionaddr);
}
function attack (){
auction.bid{value: msg.value}();
}
}
Once the attacker, calls attack()
and puts in a higher amount than the prevBidder
, he can never be removed from that position. This is because the attack contract does not have a receive() function which is required to receive any incoming ether or in this case the refund. Thus, the flow of the attack will be such:
User1 places a bid for 5 ether and becomes the Highest Bidder.
Attacker calls
attack()
with a value of 7 ether and becomes the new Highest Bidder. 5 ether is returned to User1.User2 places a bid for 10 ether. However, the transaction fails when 7 ether is being refunded to the attacker(since no receive() function is present).
Thus, no other bidder can ever win no matter how high his/her bid is. The attacker becomes the permanent Highest Bidder.
This is a very simplified example showing how dangerous a DOS attack can be.
There are 3 types of DOS attacks in Smart contracts:
Unexpected Revert
Block Gas Limit
Block Stuffing
The Solution?
There is no standard solution for preventing DOS since it is a very logical attack. Developers have to be very careful while creating their contracts, especially around receive()
functions and transactions in loops. A well-audited contract will give you a better picture of how likely it is to be hit by a DOS attack.
That's it for this post. I hope you now have a better understanding of some of the attacks possible on your contract. You can expect a lot more posts on Smart Contract security from here on forth!
For more informative posts, follow me on Twitter
Thank you for reading 🎉