Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 

Sperax USD (USDS)

Step-by-step

  1. Precalculate a contract address
  2. Transfer some USDS to that precalculated address
  3. Deploy the contract at the calculated address
  4. Transfer a USDS token from the contract to update its balance and trigger the rebase bug

Detailed Description

The USDS contract used the Address.isContract() library function to determine if an account is a contract. However, this check is conceptually wrong as it only works properly for already deployed contracts.

The implementation of isContract() is the following:

    function isContract(address account) internal view returns (bool) {
        // This method relies on extcodesize/address.code.length, which returns 0
        // for contracts in construction, since the code is only stored at the end
        // of the constructor execution.

        return account.code.length > 0;
    }

If isContract returns false, it does not assures that the address checked won't have deployed code across its lifecycle. Some examples that return false when calling isContract:

  • an externally-owned account
  • a contract in construction
  • an address where a contract will be created (precalculated)
  • an address where a contract lived, but was destroyed (could be abused by destroying + create2 to the same address)

The attacker precalculated the address of a Gnosis Safe and transferred funds to that precalculated address before. The token balances essentially are mappings that relate address => amounts, meaning that regardless the nature of that address or existence, it assigns balance to that account.

By sending tokens to an address that is not a contract (yet), the attacker managed to enter several conditional branches that were meant to be accessed by external accounts, getting a considerable amount of balance:

    function _isNonRebasingAccount(_account) internal returns(bool){
        bool isContract = AddressUpgradeable.isContract(_account);
        if (isContract && rebaseState[_account] == RebaseOptions.NotSet) {
            _ensureRebasingMigration(_account);
        }
        return nonRebasingCreditsPerToken[_account] > 0;
    }

    function _ensureRebasingMigration(address _account) internal {
        if (nonRebasingCreditsPerToken[_account] == 0) {
            nonRebasingCreditsPerToken[_account] = 1;
            if (_creditBalances[_account] != 0) {
                // Update non rebasing supply
                uint256 bal = _balanceOf(_account);
                nonRebasingSupply = nonRebasingSupply.add(bal);
                _creditBalances[_account] = bal;
            }
        }
    }

    function _balanceOf(address _account) private view returns(uint256){
        uint256 credits = _creditBalance[_account];
        if (credits > 0) {
            if (nonRebasingCreditsPerToken[_account] > 0) {
                return credits;
            }
            return credits.dividePrecisely(rebasingCreditsPerToken);
        }
        return 0;
    }

The address bypass allows the attacker to generate balance in a 'non contract' account. Later, once the contract is deployed, the first transfer assigns the attackers nonRebasingCreditsPerToken[_account] = 1 inside _ensureRebasingMigration(). Then, when calculating its balance it does not divide the amount of credits by rebasingCreditsPerToken and simply returns the amount of credits without rebasing.

The attacker managed to swap an equivalent of ~309K USD.

Possible mitigations

  1. Never rely on isContract or similar to check that an address will never be a contract across it's lifecycle.

Sources and references