Thunder Loan

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

Missing deploy-to-initialize atomicity on the UUPS proxy allows anyone to front-run initialize() and seize ownership of ThunderLoan

Missing deploy-to-initialize atomicity on the UUPS proxy allows anyone to front-run initialize() and seize ownership of ThunderLoan

Description

  • Normal behavior: a proxy deployment is initialized by the legitimate deployer so that OwnableUpgradeable records the deployer as owner().

  • Specific issue: ThunderLoan.initialize(address tswapAddress) (ThunderLoan.sol:139) is publicly callable by any address exactly once, and the proxy deployment and the initialize() call are separate transactions. An attacker watching the mempool front-runs the deployer's initialize() with a higher gas price, becomes the recorded owner() via the __Ownable_init() chain, and keeps every owner-gated power — including _authorizeUpgrade() at line 280.

// ThunderLoan.sol
@> function initialize(address tswapAddress) public initializer { // :139 — publicly callable, no deployer binding
__ThunderLoan_init(tswapAddress); // __Ownable_init() sets owner = msg.sender (the front-runner)
}
@> function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} // :280 — attacker-owner can swap implementation

Risk

Likelihood:

  • The deployment transaction and the initialize() transaction are separate, so a front-run window exists by construction; the attacker only needs to watch the mempool and pay a higher gas price.

  • initialize() has no access control and no deployer whitelist, so the front-run always succeeds within the window.

Impact:

  • Full protocol takeover: the attacker-owner can call setAllowedToken, updateFlashLoanFee, deleteAllowedToken and _authorizeUpgrade() (line 280) to install a malicious implementation.

  • All depositor funds can be drained through the malicious implementation or through fee/allowlist manipulation.

Proof of Concept

Explanation: deploy the implementation and an ERC1967Proxy with empty init data (proxy left uninitialized); before the legitimate deployer acts, an attacker EOA calls initialize(attackerTSwap) on the proxy — the call succeeds and the attacker becomes owner(); the attacker then calls upgradeToAndCall(maliciousImpl, "") through _authorizeUpgrade() and drains the pool. PoC requires forge verification.

// 1. address proxy = address(new ERC1967Proxy(address(new ThunderLoan()), "")); // uninitialized
// 2. Attacker front-runs: ThunderLoan(proxy).initialize(attackerTSwap);
// 3. assertEq(ThunderLoan(proxy).owner(), attacker); // ownership seized
// 4. vm.prank(attacker); ThunderLoan(proxy).upgradeToAndCall(maliciousImpl, "");
// 5. Malicious impl drains all deposited underlying.

Recommended Mitigation

Explanation: performing proxy deployment and initialize() in a single atomic transaction (factory or deployer contract) removes the front-run window entirely; asserting owner() == expectedDeployer right after deployment catches any misconfiguration before the protocol goes live.

--- a/deploy flow
+++ b/deploy flow
@@ deploy + initialize @@
- address proxy = address(new ERC1967Proxy(implementation, ""));
- ThunderLoan(proxy).initialize(tswapAddress); // separate tx — front-runnable
+ // atomic: deploy + initialize in ONE transaction via a factory
+ address proxy = factory.deployAndInitialize(
+ implementation,
+ abi.encodeCall(ThunderLoan.initialize, (tswapAddress))
+ );
+ require(ThunderLoan(proxy).owner() == msg.sender, "owner mismatch");
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!