Introduction

This piece is based on my contest finding, you can also read the submission's pull request for a brief description of the problem

Context

On October 28th, Hats Finance hosted a smart contract auditing contest for their V2 version of their own protocol. Hats Finance is a bug bounty market, so to speak, where projects can create decentralized bug bounties where the reward is deposited in a vault on chain. anyone can claim a certain share of that vault if they find and submit a valid vulnerability in said project.

The contest would last 2 weeks, with a 40,000 DAI total prize pool, which would be shared based on the number and severity of the vulnerabilities found (25k was allocated to high vulns and 12k for mediums, the rest would go for the #1 gas optimization). No medium vulnerabilities were found, however I was the auditor who submitted the high vulnerability and claimed that fraction of the prize. This article is a write-up of the exploit found and how I found it.

The start

For the past few months I have participated in a number of code4rena audit contests, honing my skills and earning some money, so this sort of contest wasn't exactly news for me, with one exception: all findings are public and must be submitted to the open repository, and the reward for a finding goes only to the first one who submits it. I find this format very interesting because the rewards for experienced auditors increases significantly compared to C4, however for new auditors will earn nothing (it was a great trade off for me cuz I was kinda broke anyway...). Given this race-style contest, I rid myself of all responsibilities for the 28th and 29th to focus here as I believed 2 full days were what I needed to find all I could possibly find.

On the code now, my first impression was on how well written their codebase was. Admittedly I can't comment too much on it as I'm writing this 2 months after the contest, but I do remember the quality writing! The protocol is designed to incentivize both depositors with with epochs (a mechanism I again admit I already forgot how it works), a HAT token incentive and so on, and auditors with, well, the fat prize pool. They used a committee model in order to review submissions which was reproduced in the code by having an admin w/ privileged features. Anynway I went to HATVault.sol to begin auditing that code file and noticed they were using OpenZeppelin's ERC4626Upgradeable.sol which drew my attention.

I have seen this pattern before

Two of the most well known implementations of ERC-4626 vaults are solmate's and openzeppelin's, I learned this through past audits, but I also learned that a "blank" implementation of them can lead to a certain vulnerability. The project was importing the OZ's version ERC4626Upgradeable.sol is an upgradeable version of ERC4626.sol. The deposit function in HATVault.sol calls deposit(), which calls previewDeposit() and finally _convertToShares().

    function _convertToShares(uint256 assets, MathUpgradeable.Rounding rounding) internal view virtual returns (uint256) {
        uint256 supply = totalSupply();
        return
            (assets == 0 || supply == 0)
                ? assets
                : assets.mulDiv(supply, totalAssets(), rounding);
    }

If shares were previously minted, the shares you receive are

amount of the asset you're depositing * total supply of shares / amount of assets currently deposited in the contract

For example, say the contract has 1000 shares and 10_000 USDC in it, if you deposit 1000 USDC, you get back 1000 * 1000 / 10_000 = 100 shares. Hold this for a moment.

Now, if the total shares equals zero, it means no deposits were done yet. The logic for the first depositor is quite simple, you mint as many as you deposit. The number of decimals for most ERC-20 tokens is 18, save for some exceptions like USDC which is 6, which is to say 1 USDC is actually 1_000_000 units for the smart contract. This means the lowest amount of USDC you can own or transfer is 0.000001 dollars, so almost nothing. So if you deposit almost nothing in a vault you mint almost no shares.

This is where the vulnerability can be found. If you're the first one to deposit in a vault you can create a very small amount of shares and subsequently transfer a significant amount of USDC directly to the contract, astronomically inflating the price of a share and breaking the intended logic. For example, if you deposit 1 unit of USDC, minting 1 share, and then transfer 1000 USDC directly to the contract's address, any future deposit will only generate a multiple equivalent of 1000 USDC in shares.

Now the first depositor made the contract malfunction: If someone deposits <1000 USDC, the transaction reverts and fails. Now if someone deposits anything between 1000 and 2000 USDC, regardless of the amount, they will only mint 1 share. For example, if you deposit 1500 USDC after the exploit, you will mint 1500 * 1 / 1000 = 1 share due to rounding issues. And finally since the total amount in the SC is 2500 USDC, with total supply of shares being 2, the price per share is 1250 USDC, the first depositor can withdraw their share and receive 1250 USDC back, going home with a stolen profit.

The code above already had logic which reverted on zero shares mints in a parent function, but in the case it didn't, the vulnerability would be even more critical, as future depositors could send low values and mint zero shares and the first depositor could be able to withdraw the full amount in the contract.

Fixing

After spending an embarrassing long amount of time trying to reproduce the exploit in hardhat due to a bug, around 4 AM UTC I was able to submit that vulnerability and go to bed with a clear conscience. For the next day I inspected the remaining contracts and couldn't find anything of significance so that was all my contribution.

My proposed fix was the same used for uniswap V2, which is to simply create 1000 shares and send it to address zero. It's not an elegant solution but unless someone slams 100 million dollars in the vault in order to steal pennies it does the job.

univ2solution
The uniswap solution, note that MINIMUM_LIQUIDITY is 1000

Another solution I came up with was to mint shares relative to your deposit and the total assets instead of the price per share. In a deposit, we start by calculating the fraction of the pot which is yours, with the following calculus:

100% * your deposit / amount in the contract previous to your deposit

So for instance, if theres 1000 USDC in the vault, and you deposit another 1000, you will mint 100 shares (100 is just for the sake of example, in order to avoid rounding errors in prod this value would more likely be 10_000 or 100_000, hell why not 65536 i bet its more gas efficient). When withdrawing, you get back assets in the contract * your shares / total share supply. This way the exploit becomes impossible as the first depositor will only be able to withdraw as much as they deposited themselves.

    function _convertToShares(uint256 assets, MathUpgradeable.Rounding rounding) internal view virtual returns (uint256) {
        uint256 supply = totalSupply();
        return
            (supply == 0)
                ? 100 // If first depositor, your deposit amount is always 100% of the vault's balance.
                : assets.mulDiv(100, totalAssets(), rounding);
    }

In the end I left this solution out as I just had come up with this design and thought a solution based in a tested environment would be more suitable.

I'm pretty sure the developers at the end chose to go with a minimum deposit threshold, which is to simply force a first deposit to be higher than a certain nominal value. This way the first depositor will always be forced to mint a high amount of shares, keeping the price per share at a healthy amount. The only drawback for this solution is that it doesn't work with the GUSD stablecoin, which for some reason is an ERC20 with 2 decimals.

Following events

Later on I was checking their github repository to see if someone would submit another notable exploit. I sent this report expecting 2 or 3 other people to find something notable that I may have missed but to my surprise there were none, which means I earned the full high vulnerability pot prize (I really wasn't expecting to earn so much LOL). After the contest was finished me and the devs started chatting on TG on possible solutions which I commented above and I eventually got paid.

Finally, i wanna thank the hats finance team for hosting the contest, it was good and fun and i actually didn't knew this exploit was possible until this.

return