BlazCTF 2024 Cyber Cartel

0x00. 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.
0x01. 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:

0x02. 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.
0x03. 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.
0x04. 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.
0x05. 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.
0x06. 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); |
0x07. Solution
To summarize, we can follow these steps to solve this challenge:
- Create a
BodyGuard::Proposalthat callsCartelTreasury::gistCartelDismiss, which resets theCartelTreasury::bodyGuardvalue 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::proposefunction and pass the parameters obtained from the previous steps. - With
CartelTreasury::bodyGuardnow set to the zero address, we can bypass theguardmodifier and callCartelTreasury::doomto 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!
0x08. 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 : 2025-09-13 17:04:58
- Link: https://redefine-nine.vercel.app/2024/09/24/CTF/CTF/BlazCTF-2024-Cyber-Cartel/
- License: This work is licensed under CC BY-NC-SA 4.0.