BlazCTF 2024 Cyber Cartel
Introduction
This is the first article for the BlazCTF 2024 challenge writeup. The challenge is titled Cyber Cartel
, under the Solidity
category. During the competition, 35 teams solved this challenge.
BlazCTF 2024 Cyber Cartel Challenge Link: https://github.com/fuzzland/blazctf-2024/tree/main/cyber-cartel
First, let’s check out the setup for the challenge by navigating to cyber-cartel/project/script/Deploy.s.sol
.
Overview
In the deployment script, the deployer creates the CartelTreasury
contract and three guardian addresses, passing these values as parameters to the BodyGuard
contract. Then, the bodyguard
is passed to the CartelTreasury::initialize
function. Finally, the cartel
is passed into the challenge contract, and by checking the contract’s constructor, we can see that the address is set to the TREASURY
.
During the setup, the deployer
transfers 10 ether to each guardian and 777 ether to the cartel
contract. Our objective is to drain all the ether from the Challenge::Treasury
, that is, the 777 ether stored in the cartel
contract.
1 | function isSolved() external view returns (bool) { |
So, the relation among these three challenge now is as follows:
CartelTreasury
How can we stole all the ether from the cartel
contract? We can checkout all the ether transfer related function in the cartel
contract first.
There are two function that contains the ether transfer logic: CartelTreasury::salary
and CartelTreasury::doom
.
There is a time delay between each withdrawal in CartelTreasury::salary
, and each action only withdraws 0.0001 ether, which may be too slow for us. On the other hand, CartelTreasury::doom
seems much more straightforward, as it transfers all ether directly to the msg.sender
. However, we cannot invoke this function directly at the moment due to the restrictions in the guarded
modifier. The bodyGuard
address is nonzero and points to the cartel
contract address now.
1 | modifier guarded() { |
Can we bypass the restriction by setting the CartelTreasury::bodyGuard
address to the zero address? While CartelTreasury::gistCartelDismiss
looks great, it is also protected by the guard
modifier. What about the CartelTreasury::initialize
function? Unfortunately, that won’t work either, as the require
statement will fail since the bodyGuard
variable is no longer set to the zero address.
The only way to interact with the cartel
contract is through the bodyGuard
contract. Now, let’s focus on the BodyGuard
contract.
BodyGuard
The BodyGuard
contract functions like a multisig wallet, where users can propose actions, collect the required signatures, and then call BodyGuard::propose
to execute the action. From the deployment script, we know that there are 3 guardians, or signers, for this multisig wallet.
I checked whether the private keys for these guardian addresses could be found online, as some challenges can only be solved by this method. For example, this challenge: https://github.com/fuzzland/blazctf-2023/tree/main/challenges/be-billionaire-today
Unfortunately, we couldn’t find what we were looking for, so we still need to break down the contract.
BodyGuard::propose
The function first checks the expiredAt
timestamp to ensure the proposal hasn’t expired and the nonce
value to prevent signature replay issue. It then verifies if the caller is one of the signers. Since we have the player’s private key and the player is one of the guardians, we only need to submit two signatures here.
1 | uint256 minVotes_ = minVotes; |
Next, the function verifies if the number of signatures meets the required BodyGuard::minVotes
, comparing it with the length of the signature array, and validates the signatures using BodyGuard::validateSignatures
. After this, it updates the lastNonce
value and executes the action.
The latter part of the contract seems straightforward. Here, we can interact with the cartel
contract and craft calldata to invoke the CartelTreasury::gistCartelDismiss
function, and then we can call CartelTreasury::doom
to drain all the funds.
BodyGuard::validateSignatures
However, the earlier part of the contract raises concerns. Can we submit duplicate signatures, since the function only checks if the number of signatures is sufficient? In the BodyGuard::validateSignatures
function, it recovers the signatures, hashes them, and compares the local variable signHash
to ensure no duplicates.
This is a common pattern to prevent duplicate values in an array — if a duplicate signature is submitted, it will generate the same signature hash, and the value will not be greater than the previous signHash
.
BodyGuard::recoverSigner
Alright, for the next function, it seems particularly weird:
1 | function recoverSigner(bytes32 digest, bytes memory signature) public pure returns (address) { |
This function aims to recover the signature through the ecrecover
precompiles. I fetch the ECDSA
library from Oppenzeppelin for comparison, here I remove all the comment for simplicity:
1 | function tryRecover(bytes32 hash, bytes memory signature) internal pure returns (address, RecoverError, bytes32) { |
It seems much more complex, doesn’t it? In the OpenZeppelin
version, there’s much more input validation. If you dig deeper into the library, you’ll find that it checks the following:
(1) The signature length must be 65 bytes.
(2) The uint256(s) value must not exceed 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0
.
(3) The v
value must be either 27 or 28.
You can find more details on why these restrictions are necessary in the link below:
What we could potentially exploit here is the signature length validation. Since BodyGuard::recoverSigner
only extracts the first 65 bytes of the signature, we can append additional data after a valid signature, this leads to a different signature hash and bypasses the signature hash comparison in BodyGuard::validateSignatures
. The last two validation checks might be useless for our purposes, as creating a faulty signature won’t help us bypass the checks.
Below is a simple proof-of-concept: both sig0
and sig1
are valid signatures that return the player’s address, as only the first 65 bytes are extracted by BodyGuard::recoverSigner
. However, in the meanwhile, the signatures will produce distinct signature hashes.
1 | bytes32 digest = hashProposal(proposal); |
Solution
To summarize, we can follow these steps to solve this challenge:
- Create a
BodyGuard::Proposal
that callsCartelTreasury::gistCartelDismiss
, which resets theCartelTreasury::bodyGuard
value to the zero address. - Generate a valid signature using the player’s private key, then create two signatures by appending random data to the valid one. Sort the signature hashes to ensure they are in increasing order.
- Call the
BodyGuard::propose
function and pass the parameters obtained from the previous steps. - With
CartelTreasury::bodyGuard
now set to the zero address, we can bypass theguard
modifier and callCartelTreasury::doom
to drain all the ether stored in the contract.
The corresponding Foundry script for the solution is as follows:
1 | // SPDX-License-Identifier: MIT |
Now we’ve drained all the ether from the cartel
. Well done! We’ve successfully solved a challenge in BlazCTF 2024!
Closing
When using ECDSA
signatures, several security concerns should be considered, including:
- Signature replay attacks when signatures are not properly tracked or invalidated after use: https://github.com/SunWeb3Sec/DeFiVulnLabs/blob/main/src/test/SignatureReplayNBA.sol
- Signature replay due to the absence of a nonce value: https://github.com/SunWeb3Sec/DeFiVulnLabs/blob/main/src/test/SignatureReplay.sol
- Signature replay across multiple chains if the chain ID is not included in the message hash: https://www.quicknode.com/guides/ethereum-development/smart-contracts/what-are-replay-attacks-on-ethereum
- The ecrecover function returning a zero address on failure: https://github.com/SunWeb3Sec/DeFiVulnLabs/blob/main/src/test/ecrecover.sol
Thank you for reading!
- Title: BlazCTF 2024 Cyber Cartel
- Author: Louis Tsai
- Created at : 2024-09-24 15:58:20
- Updated at : 2024-12-12 08:50:51
- Link: https://redefine-nine.vercel.app/2024/09/24/CTF/BlazCTF-2024-Cyber-Cartel/
- License: This work is licensed under CC BY-NC-SA 4.0.