← Back to Blog

DeFi Composability Security: Integration Attack Surfaces When Protocols Call Each Other

2026-04-18 composability integration defi callback external call solidity security 2026

DeFi's "money lego" promise — protocols that snap together to create new financial primitives — is also its greatest security liability. Every external protocol call is a trust boundary. You are not just trusting the code you wrote; you are trusting the entire call stack of whatever system you are integrating with, including its upgrade history, its admin keys, and its economic assumptions.

This article covers six vulnerability classes that emerge specifically at integration points between DeFi protocols. Each one has caused material losses in production systems. Each one is detectable with the right tooling and the right audit mindset.


1. Callback Reentrancy via Flash Loan

Flash loan protocols like Aave deliver funds and then call back into the borrower's contract before the loan is repaid. This callback pattern creates a classic reentrancy surface that many integrations miss because the reentrancy does not originate from a withdraw or transfer call — it originates from the lending pool itself.

Vulnerable pattern:

// VULNERABLE: no reentrancy guard on the callback receiver
contract FlashLoanIntegration {
    ILendingPool public immutable lendingPool;
    mapping(address => uint256) public balances;

    function executeOperation(
        address asset,
        uint256 amount,
        uint256 premium,
        address initiator,
        bytes calldata params
    ) external returns (bool) {
        // Business logic runs here while loan is outstanding
        _doSomethingWithFunds(asset, amount);

        // Approve repayment
        IERC20(asset).approve(address(lendingPool), amount + premium);
        return true;
    }

    function withdraw(address asset, uint256 amount) external {
        require(balances[msg.sender] >= amount, "insufficient balance");
        balances[msg.sender] -= amount;
        IERC20(asset).transfer(msg.sender, amount);
    }
}

An attacker deploys a contract that implements executeOperation. Inside that callback, before the flash loan is repaid, they call back into withdraw. Because no reentrancy guard protects withdraw, the attacker drains funds using the credit window opened by the outstanding flash loan.

The attack vector also extends to msg.sender verification. If executeOperation does not check that the caller is the legitimate lending pool address, any contract can call it directly and trigger the business logic without ever taking a flash loan.

Fixed pattern:

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract FlashLoanIntegrationFixed is ReentrancyGuard {
    ILendingPool public immutable lendingPool;
    mapping(address => uint256) public balances;

    function executeOperation(
        address asset,
        uint256 amount,
        uint256 premium,
        address initiator,
        bytes calldata params
    ) external nonReentrant returns (bool) {
        // Verify the caller is the expected lending pool
        require(msg.sender == address(lendingPool), "unauthorized caller");
        require(initiator == address(this), "unauthorized initiator");

        _doSomethingWithFunds(asset, amount);

        IERC20(asset).approve(address(lendingPool), amount + premium);
        return true;
    }

    function withdraw(address asset, uint256 amount) external nonReentrant {
        require(balances[msg.sender] >= amount, "insufficient balance");
        balances[msg.sender] -= amount;
        IERC20(asset).transfer(msg.sender, amount);
    }
}

Apply nonReentrant to every function that can be reached during the callback window, and always validate msg.sender against the known protocol address.


2. Assuming an External Protocol Will Always Be Solvent

Compound-style cTokens expose an exchangeRateCurrent() function that converts between cToken balances and their underlying asset value. Many yield aggregators and collateral vaults use this to price positions. The hidden assumption is that this rate only ever increases — and that Compound itself is always healthy.

Vulnerable pattern:

// VULNERABLE: blind trust in external exchange rate
contract YieldVault {
    ICToken public immutable cToken;

    function getPositionValue(uint256 cTokenAmount) public view returns (uint256) {
        // Assumes exchangeRate is always valid and monotonically increasing
        uint256 exchangeRate = cToken.exchangeRateStored();
        return (cTokenAmount * exchangeRate) / 1e18;
    }

    function calculateCollateral(address user) external view returns (uint256) {
        return getPositionValue(cTokenBalances[user]);
    }
}

If Compound accumulates bad debt, suffers an oracle exploit, or undergoes an emergency migration that resets the exchange rate, the value returned becomes meaningless or dangerously incorrect. A vault using this value as collateral may allow massive over-borrowing against what is now worthless collateral.

Fixed pattern:

contract YieldVaultFixed {
    ICToken public immutable cToken;
    uint256 public constant MIN_EXCHANGE_RATE = 1e17;  // 0.1 underlying per cToken minimum
    uint256 public constant MAX_EXCHANGE_RATE = 1e20;  // sanity ceiling
    uint256 public lastKnownRate;

    function getPositionValue(uint256 cTokenAmount) public returns (uint256) {
        uint256 exchangeRate = cToken.exchangeRateCurrent();

        // Sanity bounds check
        require(exchangeRate >= MIN_EXCHANGE_RATE, "exchange rate below floor");
        require(exchangeRate <= MAX_EXCHANGE_RATE, "exchange rate above ceiling");

        // Monotonicity check: rate should not drop significantly
        if (lastKnownRate > 0) {
            require(exchangeRate >= (lastKnownRate * 99) / 100, "rate dropped unexpectedly");
        }
        lastKnownRate = exchangeRate;

        return (cTokenAmount * exchangeRate) / 1e18;
    }
}

External protocol state should always be validated against reasonable bounds before it influences your own protocol's accounting.


3. Silent Protocol Migration — Hardcoded Addresses Go Stale

When a protocol hardcodes an external router or pool address as a constant, it assumes that contract lives at that address forever and behaves the same way forever. DeFi protocols migrate. Uniswap V2 was followed by V3. Curve has deployed dozens of pool variants. An old router may become deprecated, may have reduced liquidity, or in the worst case, may be replaced by an attacker who exploits a selfdestruct-then-redeploy pattern on the same address.

Vulnerable pattern:

// VULNERABLE: hardcoded external protocol address
contract LiquidityManager {
    // This address is immutable — what happens when V4 launches?
    address public constant UNISWAP_ROUTER = 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D;

    function addLiquidity(
        address tokenA,
        address tokenB,
        uint256 amountA,
        uint256 amountB
    ) external {
        IUniswapV2Router(UNISWAP_ROUTER).addLiquidity(
            tokenA, tokenB, amountA, amountB,
            0, 0, msg.sender, block.timestamp
        );
    }
}

Fixed pattern with governance timelock:

import "@openzeppelin/contracts/access/Ownable2Step.sol";

contract LiquidityManagerFixed is Ownable2Step {
    address public uniswapRouter;
    address public pendingRouter;
    uint256 public routerUpdateTime;
    uint256 public constant TIMELOCK_DELAY = 2 days;

    event RouterUpdateProposed(address indexed newRouter, uint256 effectiveAt);
    event RouterUpdated(address indexed oldRouter, address indexed newRouter);

    function proposeRouterUpdate(address newRouter) external onlyOwner {
        require(newRouter != address(0), "invalid address");
        pendingRouter = newRouter;
        routerUpdateTime = block.timestamp + TIMELOCK_DELAY;
        emit RouterUpdateProposed(newRouter, routerUpdateTime);
    }

    function applyRouterUpdate() external onlyOwner {
        require(block.timestamp >= routerUpdateTime, "timelock not elapsed");
        require(pendingRouter != address(0), "no pending update");
        emit RouterUpdated(uniswapRouter, pendingRouter);
        uniswapRouter = pendingRouter;
        pendingRouter = address(0);
    }
}

Protocol addresses that reference external systems must be updatable through governance with a timelock long enough for users to exit if they disagree with the new integration target.


4. Missing Return Value Check on External DeFi Calls

IUniswapV2Router.swapExactTokensForTokens() returns uint[] memory amounts — an array containing the input and output amounts for every hop in the swap path. The final element is the actual amount of output token received. Many integrations call the function and discard this return value entirely.

Vulnerable pattern:

// VULNERABLE: swap result is ignored
contract TokenConverter {
    IUniswapV2Router public immutable router;

    function convert(
        address tokenIn,
        address tokenOut,
        uint256 amountIn
    ) external {
        IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn);
        IERC20(tokenIn).approve(address(router), amountIn);

        address[] memory path = new address[](2);
        path[0] = tokenIn;
        path[1] = tokenOut;

        // Return value discarded — actual output amount is unknown
        router.swapExactTokensForTokens(
            amountIn, 0, path, msg.sender, block.timestamp
        );
    }
}

With amountOutMin = 0 and no return value validation, an MEV bot or a pool manipulator can grief the caller into receiving almost nothing. The function succeeds — the transaction does not revert — but the protocol has silently accepted a catastrophic slippage outcome.

Fixed pattern:

contract TokenConverterFixed {
    IUniswapV2Router public immutable router;
    uint256 public constant MAX_SLIPPAGE_BPS = 100; // 1%

    function convert(
        address tokenIn,
        address tokenOut,
        uint256 amountIn,
        uint256 expectedAmountOut
    ) external {
        IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn);
        IERC20(tokenIn).approve(address(router), amountIn);

        address[] memory path = new address[](2);
        path[0] = tokenIn;
        path[1] = tokenOut;

        uint256 minOut = (expectedAmountOut * (10000 - MAX_SLIPPAGE_BPS)) / 10000;

        uint256[] memory amounts = router.swapExactTokensForTokens(
            amountIn, minOut, path, msg.sender, block.timestamp
        );

        // Validate actual output meets expectations
        require(amounts[amounts.length - 1] >= minOut, "insufficient output");
    }
}

Capture and validate every return value from external DeFi calls. The function signature is a contract — the return value is part of that contract.


5. Incorrect Slippage on Zap and Aggregator Calls

Zap contracts convert a single token into an LP position in a single transaction, often routing through an aggregator like 1inch or Paraswap. When the zap call goes out with amountOutMin = 0, the entire swap is exposed to sandwich attacks: an MEV bot frontruns the swap to move the price, lets the zap execute at the worse price, then backtracks to pocket the difference. The protocol receives fewer LP tokens than the economic model assumed.

Vulnerable pattern:

// VULNERABLE: aggregator called with zero slippage protection
contract ZapManager {
    IAggregator public immutable aggregator;

    function zapIntoLP(
        address tokenIn,
        address lpToken,
        uint256 amountIn,
        bytes calldata aggregatorData
    ) external {
        IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn);
        IERC20(tokenIn).approve(address(aggregator), amountIn);

        // amountOutMin = 0: MEV sandwich is now free money for the attacker
        aggregator.swap(tokenIn, lpToken, amountIn, 0, aggregatorData);
    }
}

Fixed pattern:

contract ZapManagerFixed {
    IAggregator public immutable aggregator;
    IOracle public immutable oracle;
    uint256 public constant MAX_SLIPPAGE_BPS = 50; // 0.5%

    function zapIntoLP(
        address tokenIn,
        address lpToken,
        uint256 amountIn,
        bytes calldata aggregatorData
    ) external {
        IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn);
        IERC20(tokenIn).approve(address(aggregator), amountIn);

        // Calculate expected LP output using oracle pricing before the swap
        uint256 expectedLPOut = oracle.getExpectedLP(tokenIn, lpToken, amountIn);
        uint256 minLPOut = (expectedLPOut * (10000 - MAX_SLIPPAGE_BPS)) / 10000;

        uint256 lpReceived = aggregator.swap(
            tokenIn, lpToken, amountIn, minLPOut, aggregatorData
        );

        require(lpReceived >= minLPOut, "zap: insufficient LP output");
    }
}

Always compute the minimum acceptable output using an independent price source — not the aggregator's own quote — before calling an aggregator. Pass that minimum into the call itself so that any sandwich attempt causes a revert.


6. Assuming ERC-20 approve + transferFrom Is Atomic

When a protocol needs to authorize another protocol to spend tokens on its behalf, it typically calls approve and then calls a function on the external contract. If these two operations happen in separate transactions — or even in separate blocks within the same sequence — the approve is live in the mempool before the spend is triggered. An attacker watching the mempool can front-run by calling the external protocol's transferFrom-consuming function to drain the full approved amount before the legitimate call arrives.

Vulnerable pattern:

// VULNERABLE: approve and spend are separate transactions
contract ProtocolIntegration {
    IERC20 public immutable token;
    IProtocolB public immutable protocolB;

    // Transaction 1: user calls this
    function approveProtocolB(uint256 amount) external {
        token.approve(address(protocolB), amount);
    }

    // Transaction 2: user calls this later — gap is exploitable
    function depositIntoProtocolB(uint256 amount) external {
        protocolB.deposit(token, amount, msg.sender);
    }
}

Between the two transactions, an attacker calls protocolB.deposit() using the protocol's own approval, directing funds to themselves.

Fixed pattern using atomic permit:

import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol";

contract ProtocolIntegrationFixed {
    IERC20Permit public immutable token;
    IProtocolB public immutable protocolB;

    // Single transaction: permit + spend are atomic
    function depositIntoProtocolBWithPermit(
        uint256 amount,
        uint256 deadline,
        uint8 v, bytes32 r, bytes32 s
    ) external {
        // permit() sets the allowance — no separate approve transaction needed
        token.permit(msg.sender, address(this), amount, deadline, v, r, s);

        // Immediately consume exactly that allowance
        token.transferFrom(msg.sender, address(this), amount);
        token.approve(address(protocolB), amount);
        protocolB.deposit(token, amount, msg.sender);

        // Reset allowance to zero after use
        token.approve(address(protocolB), 0);
    }
}

For tokens that do not support EIP-2612 permit, use safeIncreaseAllowance and approve only the exact amount needed for the immediate operation. Never leave standing allowances.


What ContractScan Detects

ContractScan's static analysis and AI-assisted audit engine targets these composability vulnerabilities directly. Automated detection catches the patterns before code reaches production.

Vulnerability Detection Method Severity
Callback reentrancy via flash loan Tracks executeOperation / callback implementations for missing nonReentrant and absent msg.sender validation Critical
Unchecked external protocol solvency Flags raw external rate consumption with no bounds or monotonicity validation High
Hardcoded protocol addresses Detects immutable or constant external contract references with no upgrade path Medium
Missing return value on swap calls Identifies discarded return values from known DEX router interfaces High
Zero slippage on aggregator calls Flags amountOutMin = 0 patterns in swap and zap calls High
Non-atomic approve + transferFrom Detects approve calls not followed immediately by transferFrom or permit in the same transaction Medium

Run a full composability audit on your protocol at https://contractscan.io. ContractScan maps every external call in your codebase, classifies each one by trust boundary type, and surfaces the integration assumptions your code makes about external systems.


Conclusion

Every external protocol call your contract makes is an assumption encoded in code. You assume the external protocol is solvent. You assume its router address is correct. You assume its callback does not give an attacker a reentrancy window. You assume its return values are meaningful. When those assumptions break — because of a protocol upgrade, a market event, or a deliberate exploit — your contract absorbs the damage.

The six vulnerability classes in this post share a root cause: protocols that compose with external systems without explicitly modeling the failure modes of those systems. Defensive composability means treating every external call as potentially hostile, validating every return value, and building upgrade paths for every external dependency.


Important Notes

This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →