This piece is based on the follow finding in code4rena's tapioca contest

Introduction & Context

univ2solution
You've heard of permanently locked tokens, but have you ever heard about doing it on purpose for an attack?

In July 2023, C4 hosted a contest for Tapioca, one of the largest contests in the platform with about 13.5k SLOC to be reviewed. I participated in the contest but only for a short period of time as I had other matters to deal with. I, however, was able to dig one of the most interesting bugs I've ever found.

Tapioca operates with 3 particular tokens: TAP, oTAP and twTAP. TAP is the standard token, twTAP is the time weighted token used for governance you can mint by locking TAP, and oTAP is an ERC721 call option which in this context is airdropped to users. oTAP rewards you with TAP, which is how you mint TAP tokens in the first place. There's no need to stress about oTAP however as only TAP/twTAP are relevant for this writing. The function to lock your TAP exists in the twTAP contract and is explained below.

The governance entry point

In order to take part in governance, one must call the participate() function in the contract, here. Inside this function, there's a check for minimum duration of your lock, and a TAP transferFrom to the contract. _computeMagnitude() is called to calculate a magnitude of your voting power based around your lock duration. The contract then uses this magnitude to calculate a multiplier which is later required to get your final voting power.


        function participate(
        address _participant,
        uint256 _amount,
        uint256 _duration
    ) external returns (uint256 tokenId) {
        require(_duration >= EPOCH_DURATION, "twTAP: Lock not a week");

        // Transfer TAP to this contract
        tapOFT.transferFrom(msg.sender, address(this), _amount);

        // Copy to memory
        TWAMLPool memory pool = twAML;

        uint256 magnitude = computeMagnitude(_duration, pool.cumulative);
        bool divergenceForce;
        uint256 multiplier = computeTarget(
            dMIN,
            dMAX,
            magnitude,
            pool.cumulative
        );
        . . .
        // Save twAML participation
        // Casts are safe: see struct definition
        uint256 votes = _amount * multiplier;
    

Have you caught the bug yet? Well here it is: there's no upper limit to your lock duration. As a consequence, you may join with a very small amount of tokens but with a lot of duration. Let's use my PoC case where you lock for type(uint56).max - block.timestamp seconds, which is about 2.3 billion years (did you know earth will be uninhabitable in 1.3 billion years? yeah its a lot of time). As a consequence, computeMagnitude() returns a blown up value, and later in the function when the average magnitude is calculated, it will easily surpass the divergence force. Future depositors will have their voting power forced to the lowest given the multiplier, calculated in the function below, will return the very minimum amount for the first few hundreds of participants.

    function computeTarget(
            uint256 _dMin,
            uint256 _dMax,
            uint256 _magnitude,
            uint256 _cumulative
        ) internal pure returns (uint256) {
            if (_cumulative == 0) {
                return _dMax;
            }
            uint256 target = (_magnitude * _dMax) / _cumulative;
            target = target > _dMax ? _dMax : target < _dMin ? _dMin : target;
            return target;
        }
    

The fix for this bug is quite simple: assign a maximum lock duration. What's interesting with this patch however is that a maximum amount is more subjective than one would imagine. This bug would technically (but with a much, much diminished impact) still happen if someone were to participate in the protocol for 50 years. I don't know much about Tapioca but I'm not confident anything we're using on the blockchain nowadays will be active for that long, so 50 years would effectively be "forever" in this case right? Knowing this I thought about how long would someone reasonable lock tokens in a contract for and I concluded 10 years would be a reasonable amount to submit. Anyway the devs chose a max duration of 100 years.

Because of how the impact of the vulnerability differs based on how early you join the system, I coded and submitted 3 different tests. In hindsight, I went too far with such a large PoC. If I were to find this bug again I know for a fact I'd submit a single, most impactful PoC. A very detailed proof of how this impacted the protocol may have however helped this submission to be selected for report, which is always nice to see. The proof of concept tests can be found here

My submission was selected for report so thats cool. anyway, I wanted to make this post for a long time and it was harder then I anticipated. Turns out, its because I procastinated for so long I forgot how it even worked in the first place (oops).

return