Thunder Loan

AI First Flight #7
Beginner FriendlyFoundryDeFiOracle
EXP
View results
Submission Details
Impact: high
Likelihood: medium
Invalid

_autorizeUpgrade on ThunderLoan.sol doesn't check if new imnplementation implement correct interface

Root + Impact

Description

  • In the UUPS pattern, `_authorizeUpgrade()` is the function responsible for controlling who can upgrade the implementation and check if new implementation is compatible with interface.

  • In ThunderLoan.sol the function does not verify that `newImplementation` is compatible with the expected protocol interface. An owner could upgrade to a contract that compiles and deploys successfully but does not correctly implement the ThunderLoan

function _authorizeUpgrade(
address newImplementation
) internal override onlyOwner {
@> //There are no checks
}

Risk

Likelihood:

  • This issue may arise each time the implementation contract undergoes an upgrade passing non-compatible contract.

Impact:

  • If the owner upgrades to an incompatible implementation, all core protocol functions become permanently broken — liquidity providers lose access to their deposited funds via `redeem()`, active flash loans cannot be repaid via `repay()`, and no new loans can be issued via `flashloan()`.

  • Unlike most vulnerabilities that can be mitigated by pausing the protocol or deploying a fix, an upgrade to an incompatible implementation under UUPS is irreversible — if the new implementation does not expose a valid `upgradeTo()` function, the proxy is permanently bricked with no recovery path, resulting in total loss of all funds locked in the protocol.

Proof of Concept

The test verifies that `_authorizeUpgrade()` does not validate interface compatibility before allowing an upgrade. First, it confirms that `deposit()` works correctly under the original implementation. Then, the owner upgrades the proxy to a completely incompatible contract — the upgrade succeeds because `_authorizeUpgrade()` only checks `onlyOwner`. Finally, the test proves the impact by showing that both `deposit()` and `redeem()` revert after the upgrade, permanently locking all deposited funds with no recovery path.

contract POCtest is Test {
ThunderLoan thunderLoanImplementation;
MockPoolFactory mockPoolFactory;
ERC1967Proxy proxy;
ThunderLoan thunderLoan;
ERC20Mock weth;
ERC20Mock tokenA;
ERC20Mock tokenB;
ERC20Mock6Decimals tokenWith6Decimals;
AssetToken assetToken6Decimals;
AssetToken assetTokenA;
AssetToken assetTokenB;
address depositer = makeAddr("depositer");
address flahLoanReceiver = makeAddr("flashLoanReceiver");
address flashLoanAttacker = makeAddr("flashLoanAttacker");
function setUp() public virtual {
thunderLoan = new ThunderLoan();
mockPoolFactory = new MockPoolFactory();
weth = new ERC20Mock();
tokenA = new ERC20Mock();
tokenB = new ERC20Mock();
tokenWith6Decimals = new ERC20Mock6Decimals();
mockPoolFactory.createPool(address(tokenA));
mockPoolFactory.createPool(address(tokenWith6Decimals));
proxy = new ERC1967Proxy(address(thunderLoan), "");
thunderLoan = ThunderLoan(address(proxy));
thunderLoan.initialize(address(mockPoolFactory));
assetTokenB = thunderLoan.setAllowedToken(
IERC20(address(tokenB)),
true
);
assetToken6Decimals = thunderLoan.setAllowedToken(
IERC20(address(tokenWith6Decimals)),
true
);
tokenA.mint(depositer, 50000 * 10 ** tokenA.decimals()); //fund depositer with tokenA
//fund depositer with token with 6 decimals
tokenWith6Decimals.mint(
depositer,
5000 * 10 ** tokenWith6Decimals.decimals()
); // 5000 tokens in 6-decimal representation
}
function testAutorizeUpgradeDoesntCheckCompatibilityWithInterface() public {
// SETUP: allow tokenA and fund the depositer
assetTokenA = thunderLoan.setAllowedToken(
IERC20(address(tokenA)),
true
);
vm.startPrank(depositer);
tokenA.approve(address(thunderLoan), 50000e18);
thunderLoan.deposit(IERC20(address(tokenA)), 50000e18);
vm.stopPrank();
// STEP 1: verify deposit works correctly BEFORE upgrade
uint256 depositerBalanceBefore = assetTokenA.balanceOf(depositer);
assertGt(
depositerBalanceBefore,
0,
"Depositer should have assetTokens before upgrade"
);
console.log("=== BEFORE UPGRADE ===");
console.log("Depositer assetToken balance: ", depositerBalanceBefore);
console.log("deposit() works correctly : true");
// STEP 2: owner upgrades to incompatible implementation
// _authorizeUpgrade() only checks onlyOwner — never verifies interface compatibility
IncompatibleImplementation incompatibleImpl = new IncompatibleImplementation();
vm.prank(thunderLoan.owner());
// This succeeds — _authorizeUpgrade() does NOT verify IThunderLoan compatibility
thunderLoan.upgradeTo(address(incompatibleImpl));
console.log("=== AFTER UPGRADE ===");
console.log("Upgraded to IncompatibleImplementation: true");
// STEP 3: cast proxy to IThunderLoanFixed and try to call deposit()
// The proxy now points to an implementation that does NOT have deposit()
IThunderLoanFixed thunderLoanFixed = IThunderLoanFixed(address(proxy));
uint256 newDepositAmount = 1000e18;
tokenA.mint(depositer, newDepositAmount);
vm.startPrank(depositer);
tokenA.approve(address(proxy), newDepositAmount);
// deposit() no longer exists in the new implementation
// the call will hit the fallback or revert with no function selector match
vm.expectRevert();
thunderLoanFixed.deposit(address(tokenA), newDepositAmount);
vm.stopPrank();
console.log("deposit() after upgrade : REVERTED");
console.log("Protocol is bricked : true");
// STEP 4: confirm also that existing depositer can't redeem their funds
vm.startPrank(depositer);
vm.expectRevert();
thunderLoanFixed.redeem(address(tokenA), depositerBalanceBefore);
vm.stopPrank();
console.log("redeem() after upgrade : REVERTED");
console.log("Depositer funds are locked : true");
}
}

This is the correct interfac

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IThunderLoanFixed {
function initialize(address tswapAddress) external;
function deposit(address token, uint256 amount) external;
function redeem(address token, uint256 amountOfAssetToken) external;
function flashloan(
address receiverAddress,
address token,
uint256 amount,
bytes calldata params
) external;
function repay(IERC20 token, uint256 amount) external;
function setAllowedToken(
address token,
bool allowed
) external returns (address);
function getCalculatedFee(
address token,
uint256 amount
) external view returns (uint256);
function updateFlashLoanFee(uint256 newFee) external;
function isAllowedToken(address token) external view returns (bool);
function getAssetFromToken(address token) external view returns (address);
function isCurrentlyFlashLoaning(
address token
) external view returns (bool);
function getFee() external view returns (uint256);
function getFeePrecision() external view returns (uint256);
}

This is the non-compatible new implementation:

import {
UUPSUpgradeable
} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {
Initializable
} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract IncompatibleImplementation is Initializable, UUPSUpgradeable {
function initialize() external initializer {
__UUPSUpgradeable_init();
}
function _authorizeUpgrade(address newImplementation) internal override {}
function someUnrelatedFunction() external pure returns (string memory) {
return "I am incompatible";
}
}

Recommended Mitigation

Consider validating the `newImplementation` address inside `_authorizeUpgrade()` by verifying that it correctly implements the expected interface before allowing the upgrade to proceed. This ensures that any incompatible implementation is rejected at the authorization step rather than silently breaking the protocol after the upgrade is executed.

function _authorizeUpgrade(
address newImplementation
) internal override onlyOwner {
+ require(
+ IThunderLoan(newImplementation).supportsInterface(type(IThunderLoan).interfaceId),
+ "Invalid implementation"
+ );
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!