Beginner FriendlyDeFiFoundry
100 EXP
View results
Submission Details
Severity: medium
Valid

`MoneyShelf` module doesn't provide upgrade path for user deposits making them inaccessible after upgrade

Summary

All funds deposited into the MoneyShelf contract via Laundrette::depositTheCrimeMoneyInATM are at risk to be lost because there's no mechanism that ensures these funds are migrated to the MoneyVault contract when an emergency upgrade is done.

Vulnerability Details

The mafia takedown contracts allow any users to deposit USDC into a MoneyShelf contract via Laundrette::depositTheCrimeMoneyInATM in return for CrimeMoney tokens. The CrimeMoney tokens can later be redeemed via Laundrette::withdrawMoney to get back the USDC tokens.

In case of an emergency, the godfather of the protocol can run an EmergencyMigration which replaces the MoneyShelf contract with a MoneyVault contract:

function migrate(Kernel kernel, IERC20 usdc, CrimeMoney crimeMoney) public returns (MoneyVault) {
vm.startBroadcast(kernel.executor());
MoneyVault moneyVault = new MoneyVault(kernel, usdc, crimeMoney);
kernel.executeAction(Actions.UpgradeModule, address(moneyVault));
vm.stopBroadcast();
// Once the problem is solved, GodFather migrate to a new contract and redistribute manually
// all the money to gang members thanks to event monitoring and his accountant.
return moneyVault;
}

The idea is that, once upgraded, nobody can deposit funds into the protocol anymore and only the godfather can withdraw all locked up USDC on behalf of all depositors:

function depositUSDC(address, address, uint256) external pure {
revert("MoneyVault: depositUSDC is disabled");
}
function withdrawUSDC(address account, address to, uint256 amount) external {
require(to == kernel.executor(), "MoneyVault: only GodFather can receive USDC");
withdraw(account, amount);
crimeMoney.burn(account, amount);
usdc.transfer(to, amount);
}

However, there's a serious bug: the upgrade strategy doesn't take into account that the funds that live in the MoneyShelf contract need to be transferred to the MoneyVault contract (before) the upgrade is done.

This means, when the godfather runs the emergency migration, the module will be replaced and all funds will be locked in the old MoneyShelf instance forever as there's no way to retrieve them again.

Impact

After performing the emergency migration, or any other migration that upgrades the MoneyShelf module with something else, all funds deposited by users will be lost.

Tools Used

  • Manual review

  • Foundry for testing

Recommended Mitigation

If the framework that is used (Default in this case) doesn't have a solution for running something like hooks, before and after a module is upgraded, the only way to ensure funds are not lost is to either never do an emergency upgrade, or extend the MoneyShelf contract with a function to migrate the funds to the new module.

A very rudimentary version of that could look like this:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { CrimeMoney } from "src/CrimeMoney.sol";
import { Kernel, Keycode } from "src/Kernel.sol";
import { Shelf } from "src/modules/Shelf.sol";
contract MoneyShelf is Shelf {
IERC20 private usdc;
CrimeMoney private crimeMoney;
constructor(Kernel kernel_, IERC20 _usdc, CrimeMoney _crimeMoney) Shelf(kernel_) {
usdc = _usdc;
crimeMoney = _crimeMoney;
}
function KEYCODE() public pure override returns (Keycode) {
return Keycode.wrap("MONEY");
}
// "permissioned" already used by Shelf functions
function depositUSDC(address account, address to, uint256 amount) external {
deposit(to, amount);
usdc.transferFrom(account, address(this), amount);
crimeMoney.mint(to, amount);
}
function withdrawUSDC(address account, address to, uint256 amount) external {
withdraw(account, amount);
crimeMoney.burn(account, amount);
usdc.transfer(to, amount);
}
+ function migrateFunds(address account) external {
+ require(msg.sender == kernel.executor(), "MoneyShelf: only GodFather can migrate funds");
+ usdc.transfer(account, usdc.balanceOf(address(this)));
+ }
}

Obviously special care has to be taken here. There's a new risk that the executor could send all the funds to any other account, resulting in the loss of all funds as well.

However, since there's already centralization risk and the executor is trusted, this might be a solution worth considering.

We can then change the EmergencyMigration script accordingly:

function migrate(Kernel kernel, IERC20 usdc, CrimeMoney crimeMoney) public returns (MoneyVault) {
vm.startBroadcast(kernel.executor());
MoneyVault moneyVault = new MoneyVault(kernel, usdc, crimeMoney);
+ // this has to happen before upgrading the module
+ address moneyShelf = address(kernel.getModuleForKeycode(Keycode.wrap("MONEY")));
+ MoneyShelf(moneyShelf).migrateFunds(address(moneyVault));
kernel.executeAction(Actions.UpgradeModule, address(moneyVault));
vm.stopBroadcast();
// Once the problem is solved, GodFather migrate to a new contract and redistribute manually
// all the money to gang members thanks to event monitoring and his accountant.
return moneyVault;
}

With these changes, the test case in the proof of concept shown below should fail.

Proof of Concept

To proof this bug with a test case, we first have to address another issue that lives in this protocol and has been covered in another submission:

The dependency configuration done in Laundrette::configureDependencies returns an incorrect list of keycodes. We need to assume this has been fixed, otherwise we'll get false positives.

To fix this issue, apply the following changes in Laundrette::configureDependencies:

function configureDependencies() external override onlyKernel returns (Keycode[] memory dependencies) {
dependencies = new Keycode[](2);
dependencies[0] = toKeycode("MONEY");
moneyShelf = MoneyShelf(getModuleAddress(toKeycode("MONEY")));
- dependencies[0] = toKeycode("WEAPN");
+ dependencies[1] = toKeycode("WEAPN");
weaponShelf = WeaponShelf(getModuleAddress(toKeycode("WEAPN")));
}

Once that is done, we can drop these changes into EmergencyMigration.t.sol to confirm the bug:

+ function test_DepositsNotTransferredBug() public {
+ // `MockUSDC` mints `1_000_000e6` token to `godFather` in tests
+ // this is our starting point
+ assertEq(usdc.totalSupply(), 1000000e6);
+ assertEq(usdc.balanceOf(godFather), 1000000e6);
+
+ // create two arbitrary users
+ address userOne = makeAddr("userOne");
+ address userTwo = makeAddr("userTwo");
+
+ // ensure they have some tokens to deposit
+ vm.startPrank(godFather);
+ usdc.transfer(userOne, 100e6);
+ usdc.transfer(userTwo, 100e6);
+ vm.stopPrank();
+
+ // double check that balances have updated correctly
+ assertEq(usdc.balanceOf(userOne), 100e6);
+ assertEq(usdc.balanceOf(userTwo), 100e6);
+ assertEq(usdc.balanceOf(godFather), 1000000e6-200e6);
+
+ // both users approve `moneyShelf` to spend their tokens
+ vm.prank(userOne);
+ usdc.approve(address(moneyShelf), 100e6);
+ vm.prank(userTwo);
+ usdc.approve(address(moneyShelf), 100e6);
+
+ // then both users deposit their tokens
+ vm.prank(userOne);
+ laundrette.depositTheCrimeMoneyInATM(userOne, userOne, 100e6);
+ vm.prank(userTwo);
+ laundrette.depositTheCrimeMoneyInATM(userTwo, userTwo, 100e6);
+
+ // ensuring funds ended up in `MoneyShelf` and users have minted `CrimeMoney`
+ assertEq(crimeMoney.balanceOf(userOne), 100e6);
+ assertEq(crimeMoney.balanceOf(userTwo), 100e6);
+ assertEq(usdc.balanceOf(userOne), 0);
+ assertEq(usdc.balanceOf(userTwo), 0);
+ assertEq(usdc.balanceOf(address(moneyShelf)), 200e6);
+ assertEq(usdc.balanceOf(godFather), 1000000e6-200e6);
+
+ // ensure `godFather` has `gangmember` role, otherwise can't withdraw funds
+ vm.prank(address(laundrette));
+ kernel.grantRole(Role.wrap("gangmember"), godFather);
+
+ // next, assume the `EmergencyMigration` needs to be performed
+ // `moneyVault` is now the new module that's used for depositing/withdrawing funds
+
+ EmergencyMigration migration = new EmergencyMigration();
+ MoneyVault moneyVault = migration.migrate(kernel, usdc, crimeMoney);
+ // `godFather` tries to withdraw funds from `MoneyVault` however,
+ // since the funds have never been transferred from `MoneyShelf`,
+ // there's nothing to withdraw - this will revert with an arithmetic underflow
+ vm.startPrank(godFather);
+ vm.expectRevert();
+ laundrette.withdrawMoney(userOne, godFather, 100e6);
+
+ // check that `moneyVault` has indeed no funds
+ // assertEq(usdc.balanceOf(address(moneyVault)), 0);
+ assertEq(usdc.balanceOf(address(moneyVault)), 200e6);
+ }
Updates

Lead Judging Commences

n0kto Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

Emergency migration leave the USDC

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.