Thunder Loan

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

# C-2: Deploy Script Leaves the Proxy Uninitialized, Letting Anyone Seize Ownership and the Price Oracle

Root + Impact

The deployment script creates the ERC1967Proxy with empty initialization calldata ("") and never calls initialize in the same transaction. ThunderLoan.initialize has no caller restriction beyond the initializer modifier and sets owner = msg.sender (via __Ownable_init) and s_poolFactory = tswapAddress (via __Oracle_init). Between deployment and the operator's first initialize call, any address can call initialize first, becoming the owner and choosing the price factory. As owner, the attacker can immediately upgradeTo a malicious implementation (draining every AssetToken) and control the fee oracle.

Description

DeployThunderLoan.run() deploys the implementation and the proxy but passes "" as the proxy's init data, so the proxy starts uninitialized. initialize (ThunderLoan.sol:139-145) is guarded only by initializer (one-shot, but first-caller-wins) — it does not verify the caller is the deployer. The intended atomic pattern is to pass abi.encodeCall(ThunderLoan.initialize, (tswap)) as the proxy constructor's data, so initialization happens in the same transaction as deployment. Because that is not done, the deployer's separate initialize call is front-runnable.

// script/DeployThunderLoan.s.sol:10-13 — proxy deployed with empty init data
ThunderLoan thunderLoan = new ThunderLoan();
@> new ERC1967Proxy(address(thunderLoan), ""); // no initialize call, no atomic init
// initialize() is expected later, in a separate transaction — a front-run window
​
// src/protocol/ThunderLoan.sol:139-145 — first caller becomes owner + picks the oracle
function initialize(address tswapAddress) external initializer {
@> __Ownable_init(); // owner = msg.sender (whoever calls first)
@> __UUPSUpgradeable_init();
@> __Oracle_init(tswapAddress); // attacker-chosen price factory
s_feePrecision = 1e18;
s_flashLoanFee = 3e15;
}

Risk

Likelihood: An attacker monitors the deployer's transactions, sees the proxy deployed without init data, and sends initialize(attackerFactory) first. Only the deployment window is exposed, but that window is created by the shipped script itself.

Impact: The attacker becomes owner and controls s_poolFactory. They can upgradeTo an implementation that calls assetToken.transferUnderlyingTo(attacker, balance) to drain all LP principal, and can make getPriceInWeth return arbitrary values. LPs and borrowers lose everything in the pool; the loss is total and unrecoverable.

Proof of Concept

The test deploys a fresh ThunderLoan implementation and a fresh ERC1967Proxy with empty data (mirroring the script), then pranks an attacker address and calls initialize(oracle). The assertion proves the attacker — not the deployer — is now owner() of the proxy.

// test/poc/Exploits.t.sol
function testExploit_UninitializedProxy_OwnershipSeized() public {
ThunderLoan freshImpl = new ThunderLoan();
ERC1967Proxy freshProxy = new ERC1967Proxy(address(freshImpl), ""); // as the script does
ThunderLoan freshLoan = ThunderLoan(address(freshProxy));
​
vm.prank(attacker);
freshLoan.initialize(address(oracle)); // attacker front-runs
​
assertEq(freshLoan.owner(), attacker, "BUG PROOF: attacker owns the uninitialised proxy");
}

Recommended Mitigation

Initialize atomically in the proxy constructor so no uninitialized window exists, and hand ownership to a multisig/timelock in the same transaction. This removes the front-run entirely because initialize cannot be called separately.

- ThunderLoan thunderLoan = new ThunderLoan();
- new ERC1967Proxy(address(thunderLoan), "");
+ ThunderLoan thunderLoan = new ThunderLoan();
+ bytes memory initData = abi.encodeCall(ThunderLoan.initialize, (tswapAddress));
+ ERC1967Proxy proxy = new ERC1967Proxy(address(thunderLoan), initData);
+ // optionally: ThunderLoan(address(proxy)).transferOwnership(multisig);

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour 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!