#48718 [SC-Insight] Contract Upgrade Completion DoS/Takeover Risk

Submitted on Jul 7th 2025 at 10:09:36 UTC by @Blobism for Audit Comp | Folks Smart Contract Library

  • Report ID: #48718

  • Report Type: Smart Contract

  • Report severity: Insight

  • Target: https://github.com/Folks-Finance/algorand-smart-contract-library/blob/main/contracts/library/Upgradeable.py

  • Impacts:

    • Temporary denial of service for more than one block

Description

Brief/Intro

The Upgradeable contract module allows any user to complete contract upgrades, which sets the contract to an uninitialized state. This means that a malicious user could complete a contract upgrade before an authorized user, potentially rendering the contract unusable until it is initialized. Furthermore, if contract initialization permissions are not carefully managed, a malicious user could then initialize the contract and give themselves an admin role.

Vulnerability Details

The Upgradeable method complete_contract_upgrade can be called by anyone, and it sets the is_initialised state of the contract to False. This means that if the child contract requires initialization to function, and a contract upgrade was scheduled, any malicious user could complete the contract upgrade, rendering the contract unusable until it gets initialized again. This is desired behavior according to the docstring, but it is highly undesireable from a security perspective, due to the potential for DoS attacks.

To be more specific, if the child contract relies on the method self._only_initialised() at all, it is very risky to allow any user to set the contract into an uninitialized state.

@abimethod(allow_actions=["UpdateApplication"])
def complete_contract_upgrade(self) -> None:
    """Complete the scheduled upgrade
    Anyone can call this method. <----------------- the issue

    Raises:
        AssertionError: If the contract is not initialised
        AssertionError: If the complete upgrade timestamp is not met
        AssertionError: If the contract SHA256 is not valid
    """
    self._only_initialised()

    # ... <checks if upgrade is scheduled> ...

    # reset to new contract version
    del self.scheduled_contract_upgrade.value
    self.version += UInt64(1)
    self.is_initialised = False # <-------- contract uninitialized

    emit(UpgradeCompleted(program_sha256, ARC4UInt64(self.version)))

Furthermore, there is a risk of contract takeover if the initialization method is not carefully designed to guard against it. This comment describes what is expected of the child contract:

"""
...
It is the responsibility of the child contract to grant the `{upgradable_admin_role}`. The `initialise()` ABI
method, which is left abstract, is the recommended place to do this. If the `{upgradable_admin_role}` is ungranted
then no account will be able to upgrade the contract.
"""

If the contract initializer is allowed to freely select who gets the admin role, a malicious user could complete the contract upgrade, then initialize the contract such that they are now the admin. This is more so an inherent risk of the design rather than a vulnerability, but it needs to be detailed more explicitly. One way to do this safely is provided in InitialisableWithCreator, which checks that the caller must be the contract creator.

So using InitialisableWithCreator removes the risk of contract takeover with a very barebones approach. However, the danger of contract takeover is generally increased by the fact that any malicious user can complete a contract upgrade themselves, then immediately attempt to initialize the contract.

Some potential fixes are:

  • restrict upgrade completion to being callable only by authorized users

  • be much more explicit in docs about the importance of securing the initialization method

Impact Details

The definite vulnerability is a temporary DoS, because anyone can complete a contract upgrade after it has been initiated, leaving the contract in an uninitialized state.

An additional risk, which is dependent upon poor initialization implementation, is that there could be contract takeover following a malicious user completing the contract upgrade.

References

See contracts/library/Upgradeable.py

Proof of Concept

Proof of Concept

This test in Upgradeable.test.ts is sufficient to demonstrate the issue. It shows how a user who is NOT the creator or an admin is able to complete the contract upgrade, setting the contract to being NOT initialized. While the test may suggest that it is desired behavior, this is highly unadvisable from a security perspective.

test("succeeds and completes scheduled upgrade", async () => {
  const { timestamp: scheduledTimestamp } = (await client.state.global.scheduledContractUpgrade())!;
  expect(await getPrevBlockTimestamp(localnet)).toBeGreaterThanOrEqual(scheduledTimestamp);

  const oldVersion = (await client.state.global.version()) || 0n;
  const programSha256 = calculateProgramSha256(approvalProgramToUpdateTo, clearStateProgramToUpdateTo);

  const res = await localnet.algorand.send.appUpdateMethodCall({
    sender: user,
    appId,
    method: client.appClient.getABIMethod("complete_contract_upgrade"),
    approvalProgram: approvalProgramToUpdateTo,
    clearStateProgram: clearStateProgramToUpdateTo,
  });

  const updatedApp = await localnet.algorand.app.getById(appId);
  expect(updatedApp.approvalProgram).toEqual(approvalProgramToUpdateTo);
  expect(updatedApp.clearStateProgram).toEqual(clearStateProgramToUpdateTo);
  expect(res.confirmations[0].logs).toBeDefined();
  expect(res.confirmations[0].logs![0]).toEqual(
    getEventBytes("UpgradeCompleted(byte[32],uint64)", [programSha256, oldVersion + 1n]),
  );
  expect(await client.state.global.scheduledContractUpgrade()).toBeUndefined();
  expect(await client.state.global.version()).toEqual(oldVersion + 1n);
  expect(await client.state.global.isInitialised()).toBeFalsy();
});

Was this helpful?