Replaying Bugs With Certora: Sorra Finance

In this blog post we’re going to deep dive into the Sorra Finance hack, find the root cause of the bug and create a certora rule which could’ve prevented the exploit.

Hack Details

Sorra Staking contract is basically a ponzi scheme. Users deposit ERC20 tokens, wait for some time, and, if there’re funds available, withdraw more ERC20 tokens.

Sorra offers 3 staking tiers (i.e. options for how long to stake ERC20 tokens):
1. 14 days with 5% APY (code)
2. 30 days with 20% APY (code)
3. 60 days with 40% APY (code)

Normal flow example:
1. User deposits 100 ERC20 tokens in tier0 (14 days staking period)
2. User waits for 14 days
3. User withdraws 105 ERC20 tokens (if there were other users who also staked in this ponzi-style contract)

Now check getPendingRewards() and _calculateRewards() methods. The root cause of the issue is that pending rewards don’t take into account already distributed rewards.

The bug in the getPendingRewards() method makes the following buggy flow possible:
1. User deposits 100 ERC20 tokens in tier0 (14 days staking period)
2. User waits for 14 days
3. User withdraws 1 wei and gets 5 ERC20 token rewards (which is expected)
4. User withdraws 1 wei again and suddenly gets ~5 ERC20 token rewards again (remember that pending rewards don’t account for already distributed ones)
5. Loop continues until the contract is out of funds

With all that in mind the Sorra Staking contract was exploited earlier this year.

Certora Rule

Now it’s time to create a certora rule that could’ve caught that bug.

Create empty sorra.conf and sorra.spec files.

Certora Config

Certora config files allow setting all of the certoraRun CLI tool options in a file instead of passing them in the CLI options.

We need to add 2 contracts to our “scene” (i.e. contracts that certora is operating on), sorraStaking (developers chose it to start with lowecase) and MockERC20 (reward token mock):

Next we need to tell certora that it should use our MockERC20 contract for rewardToken implementation:

We should also set which spec should be used for verification of the sorraStaking contract:

We’re going to use the withdrawIntegrity name for the rule that checks the bug:

Next we add a comment that will be shown in the certora dashboard:

We also set the rule_sanity so that certora could report whether the rule we’ve just created is vacuous:

Finally, we set the optimistic_loop because (as far as I understandherehere or here certora executes the loop too many times thus violating the built-in loop unwinding condition rule:

In the end we get the following certora config:

Certora Spec

Now it’s time to modify our empty sorra.spec file.

First of all we import our MockERC20 contract so that we could use it in the spec:

Next we create a withdrawIntegrity rule and define env (blockchain environment with properties like env.msg.sender, etc…) and amount (specifies the withdraw amount) variables which may take arbitrary inputs:

Then we set some sane preconditions in the withdrawIntegrity rule so that certora could provide counterexamples without overflowing (somehow using the mathint type didn’t take any effect):

Then we tell certora that there’re no vault extensions, some users have already deposited, pending rewards exist (i.e. staking time passed) and sorraStaking can’t be a msg.sender (which makes sense):

Then we setup some helper variables (most of them for ease of debugging in the certora dashboard) and call withdraw() with arbitrary environment and amount variables so that certora could analyze all possible inputs:

Finally we set the assert statement which must not violate the User must not withdraw more that expected rule. “More than expected” is actually tricky so I ended up comparing the amount to be withdrawn (plus pending rewards) with the actual withdrawn amount (again plus pending rewards). Both values (before and after the withdaw) must be equal, otherwise there’s something wrong:

In the end we get the following certora spec:

Running Certora Rule

Now, if we run certoraRun test/sorra/certora/sorra.conf we get the following result: https://prover.certora.com/output/8691664/cc74c97e12b844f099814bdf3aa0cd3a/?anonymousKey=955a9dba9a7a4689097f07ffe84bdbd124058e2c

So the counterexample is:
1. User has a balance of 99999999999999999999999999 tokens
2. User has 2 pending rewards before withdraw
3. User withdraws 3751 tokens
4. After withdrawing user has a balance of 100000000000000000000003752 tokens

Now, if we check our assert statement we get the assertion:

If we dive deeper we see that the pendingRewardsAfter variable is set to 1 in the counterexample while it must be equal to 0 after the withdraw. That pendingRewardsAfter variable is basically the root cause of the rule violation.

Sources

1. Contract: https://github.com/ryzhak/replaying-bugs-with-certora/blob/389f56905cdc6d170e8f2e4808b00d824eb31439/src/sorra/SorraStaking.sol
2. Foundry tests and certora spec: https://github.com/ryzhak/replaying-bugs-with-certora/tree/389f56905cdc6d170e8f2e4808b00d824eb31439/test/sorra

Conclusion

In this tutorial we covered the Sorra Finance hack and proved it with the Certora Prover which is a great tool (although with a steep learning curve) for catching such bugs. That’s all for today, stay safe.

Leave a Reply

Your email address will not be published. Required fields are marked *