DEV Community

ohmygod
ohmygod

Posted on

Auditing Inherited Code: How to Detect Fork-Inherited Vulnerabilities Before They Become $7M Exploits

In January 2026, SagaEVM lost $7 million because of a vulnerability they didn't write. The flaw lived in Ethermint's EVM precompile code — inherited wholesale when Saga built their chain on the Ethermint framework. The attacker crafted fake IBC messages through a helper contract, tricked the precompile bridge into believing collateral existed, and minted unlimited Saga Dollars out of thin air.

This wasn't a one-off. Compound forks have been exploited repeatedly through inherited accounting bugs. Uniswap V2 forks ship with known rounding issues. Aave forks inherit oracle integration assumptions that don't hold in new deployment contexts. The most dangerous code in your protocol is the code you didn't write but trust implicitly.

This article covers a practical toolkit and methodology for auditing fork-inherited code — the specific tools, patterns, and detection strategies that could have caught the SagaEVM vulnerability before deployment.

The Fork Inheritance Problem

When you fork a protocol, you inherit three things:

  1. The code — what you see and (hopefully) read
  2. The assumptions — what the original team took for granted
  3. The known issues — bugs that were found, reported, and maybe fixed upstream but never propagated to your fork

The SagaEVM exploit illustrates all three. Ethermint's IBC precompile code assumed that messages arriving at the precompile were pre-validated by the IBC module. In Ethermint's original context, that assumption held. In SagaEVM's deployment context — where a helper contract could feed arbitrary messages directly to the precompile — it didn't.

Original Context (Ethermint):
  IBC Module → validates messages → Precompile → processes transfer
  ✅ Messages are always validated before reaching precompile

SagaEVM Context:
  Helper Contract → calls precompile directly → processes transfer
  ❌ No validation — precompile trusts every message
Enter fullscreen mode Exit fullscreen mode

This is the context gap — the space between what upstream code assumes and what your deployment actually guarantees.

Tool 1: Diff-Based Audit with diffusc and difftastic

The first step in any fork audit is understanding what changed. Not just your modifications — also what the upstream project changed since you forked.

difftastic for Structural Diffing

Standard diff is line-based and noisy. difftastic understands syntax trees, making it invaluable for comparing Solidity, Rust, or Go codebases:

# Compare your fork against upstream
difft --language solidity upstream/contracts/ your-fork/contracts/

# For Cosmos SDK / Ethermint (Go code)
difft --language go ethermint/x/evm/precompiles/ saga/x/evm/precompiles/
Enter fullscreen mode Exit fullscreen mode

What to look for:

  • Functions where your fork removed validation checks
  • New external entry points that bypass existing validation flows
  • Changed visibility modifiers (internalpublic)
  • Modified access control patterns

diffusc for Semantic Diffing

Crytic's diffusc goes beyond syntax — it uses symbolic execution to find behavioral differences between two contract versions:

# Install
pip install diffusc

# Compare upstream vs fork
diffusc upstream/Contract.sol fork/Contract.sol --solc 0.8.24
Enter fullscreen mode Exit fullscreen mode

diffusc will flag functions where the same inputs produce different outputs or different revert conditions. For the SagaEVM case, this would have flagged the precompile's message processing function as accepting inputs that the original would have rejected.

Tool 2: Upstream CVE and Issue Tracking

Before writing a single line of audit code, check what's already been found.

Automated Upstream Scanning Script

#!/bin/bash
# fork-vuln-scanner.sh — Check upstream for known issues

UPSTREAM_REPO="evmos/ethermint"  # or compound-finance/compound-protocol
FORK_COMMIT=$(git log --format='%H' -1)

echo "=== Checking upstream security advisories ==="
gh api repos/$UPSTREAM_REPO/security-advisories --jq '.[].summary'

echo -e "\n=== Checking upstream issues labeled 'bug' or 'security' ==="
gh issue list -R $UPSTREAM_REPO -l bug -l security --state all --limit 50

echo -e "\n=== Commits to upstream since fork point ==="
# Find common ancestor
FORK_POINT=$(git merge-base HEAD upstream/main 2>/dev/null || echo "unknown")
if [ "$FORK_POINT" != "unknown" ]; then
    echo "Fork point: $FORK_POINT"
    git log --oneline $FORK_POINT..upstream/main -- "**/*precompile*" "**/*bridge*" "**/*validation*"
fi

echo -e "\n=== Checking Code4rena/Sherlock findings ==="
# Search audit contest databases
curl -s "https://raw.githubusercontent.com/code-423n4/audits/main/README.md" | \
    grep -i "$UPSTREAM_REPO" || echo "No Code4rena audits found for upstream"
Enter fullscreen mode Exit fullscreen mode

For SagaEVM, this script would have surfaced Ethermint's own IBC precompile discussions and any upstream patches that addressed message validation. Multiple Ethermint-based chains are now vulnerable because none of them tracked upstream security patches.

Tool 3: Invariant Testing for Fork Assumptions

The most powerful technique for catching fork-inherited bugs is invariant testing — defining properties that must always hold and letting a fuzzer try to break them.

Foundry Invariant Tests for Inherited Lending Code

If you've forked Compound or Aave, these invariants catch the most common inherited bugs:

// test/invariants/ForkInvariants.sol
pragma solidity ^0.8.24;

import "forge-std/Test.sol";

contract ForkLendingInvariants is Test {

    // INVARIANT 1: No minting without collateral
    // (Would have caught SagaEVM)
    function invariant_noUnbackedMinting() public {
        uint256 totalMinted = stablecoin.totalSupply();
        uint256 totalCollateral = vault.totalCollateralValue();

        assertGe(
            totalCollateral,
            totalMinted,
            "CRITICAL: Stablecoin supply exceeds collateral backing"
        );
    }

    // INVARIANT 2: Supply cap enforcement
    // (Would have caught Venus Protocol)
    function invariant_supplyCapRespected() public {
        for (uint i = 0; i < markets.length; i++) {
            address market = markets[i];
            uint256 actualSupply = IMarket(market).totalSupply();
            uint256 cap = controller.supplyCap(market);

            assertLe(
                actualSupply,
                cap,
                "Supply exceeds cap — donation attack vector"
            );
        }
    }

    // INVARIANT 3: Exchange rate monotonicity
    // (Catches share inflation / donation attacks in forked vaults)
    function invariant_exchangeRateMonotonic() public {
        uint256 currentRate = vault.exchangeRate();
        assertGe(
            currentRate,
            lastExchangeRate,
            "Exchange rate decreased — possible manipulation"
        );
        lastExchangeRate = currentRate;
    }

    // INVARIANT 4: Only validated messages mint
    // (Specific to IBC/bridge forks)
    function invariant_onlyValidatedMessagesMint() public {
        // After each action, verify that any new mints
        // correspond to validated bridge messages
        uint256 newMints = stablecoin.totalSupply() - lastTotalSupply;
        uint256 validatedDeposits = bridge.totalValidatedDeposits() - lastValidatedDeposits;

        assertLe(
            newMints,
            validatedDeposits,
            "Mints exceed validated deposits — unvalidated message bypass"
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Run with aggressive fuzzing:

forge test --match-contract ForkInvariants \
    --fuzz-runs 50000 \
    --fuzz-seed 42 \
    -vvv
Enter fullscreen mode Exit fullscreen mode

Trident Invariants for Solana Forks

If you've forked a Solana program (Marinade, Solend, Raydium), use Trident:

// trident-tests/fuzz_tests/fork_invariants.rs
use trident_client::*;

#[derive(Arbitrary)]
pub struct ForkFuzzInput {
    pub amount: u64,
    pub fake_authority: Pubkey,
}

fn invariant_no_unauthorized_mint(
    ctx: &FuzzContext,
    pre: &ProtocolSnapshot,
    post: &ProtocolSnapshot,
) -> bool {
    // If supply increased, verify it was through authorized path
    if post.token_supply > pre.token_supply {
        let mint_delta = post.token_supply - pre.token_supply;
        let collateral_delta = post.total_collateral - pre.total_collateral;

        // Collateral must increase proportionally
        assert!(
            collateral_delta >= mint_delta * pre.exchange_rate / PRECISION,
            "Unauthorized mint detected: {} tokens without collateral",
            mint_delta
        );
    }
    true
}
Enter fullscreen mode Exit fullscreen mode

Tool 4: Semgrep Rules for Fork-Specific Patterns

Custom Semgrep rules can catch the most common fork-inherited vulnerability patterns statically:

# .semgrep/fork-audit-rules.yml
rules:
  - id: unvalidated-cross-chain-message
    pattern: |
      function $FUNC($MSG) external {
        ...
        $TOKEN.mint($ADDR, $AMT);
        ...
      }
    pattern-not: |
      function $FUNC($MSG) external {
        ...
        require($VALIDATOR.validate($MSG), ...);
        ...
        $TOKEN.mint($ADDR, $AMT);
        ...
      }
    message: >
      Cross-chain message handler mints tokens without explicit validation.
      If this precompile/bridge was inherited from upstream, verify that
      the original validation flow is preserved in your deployment context.
    severity: ERROR
    languages: [solidity]
    metadata:
      category: fork-audit
      reference: "SagaEVM exploit  inherited Ethermint precompile bypass"

  - id: supply-cap-bypass-via-transfer
    pattern: |
      function $FUNC(...) {
        ...
        IERC20($TOKEN).transferFrom(..., address(this), $AMT);
        ...
      }
    pattern-not: |
      function $FUNC(...) {
        ...
        require(totalSupply() + $AMT <= supplyCap, ...);
        ...
        IERC20($TOKEN).transferFrom(..., address(this), $AMT);
        ...
      }
    message: >
      Direct token transfer to protocol without supply cap check.
      Compound-fork donation attack vector — attacker can bypass
      deposit() supply cap by transferring tokens directly.
    severity: WARNING
    languages: [solidity]
    metadata:
      category: fork-audit
      reference: "Venus Protocol supply cap donation attack"

  - id: cosmos-ibc-precompile-no-origin-check
    patterns:
      - pattern: |
          func (p *$PRECOMPILE) $METHOD(ctx sdk.Context, $ARGS) ($RET) {
              ...
          }
      - pattern-not: |
          func (p *$PRECOMPILE) $METHOD(ctx sdk.Context, $ARGS) ($RET) {
              ...
              if !p.isValidatedIBCMessage(...) { ... }
              ...
          }
    message: >
      Cosmos precompile method processes messages without IBC validation.
      In inherited Ethermint code, verify messages cannot be injected
      via helper contracts bypassing the IBC module.
    severity: ERROR
    languages: [go]
    metadata:
      category: fork-audit
      reference: "SagaEVM Ethermint precompile exploit"
Enter fullscreen mode Exit fullscreen mode

Run across your codebase:

semgrep --config .semgrep/fork-audit-rules.yml \
    --sarif -o fork-audit-results.sarif \
    contracts/ x/evm/
Enter fullscreen mode Exit fullscreen mode

Tool 5: The Fork Audit Checklist

Before deploying any forked code, run through this systematic checklist:

Pre-Deployment Fork Audit Matrix

Check Tool What It Catches
Structural diff against upstream difftastic Removed validations, changed visibility
Semantic diff diffusc Behavioral divergence from upstream
Upstream CVE/advisory scan gh api + manual review Known vulnerabilities not backported
Upstream commit scan since fork git log Security patches you missed
Prior audit findings (Code4rena, Sherlock) Manual search Known issues in upstream that may apply
Entry point enumeration Slither (slither --print call-graph) New attack surfaces your fork introduced
Invariant testing Foundry / Trident Broken fundamental assumptions
Static analysis with fork-specific rules Semgrep Pattern-matched vulnerability classes
Context gap analysis Manual Assumptions that held upstream but not in your deployment
Integration point audit Manual + slither --print human-summary How inherited code interacts with your custom code

The Context Gap Analysis (Manual but Critical)

For each inherited module, answer these questions:

  1. Who calls this code? In the original, was it called by trusted internal modules? In your fork, can external contracts call it?
  2. What validates inputs? Does the original rely on upstream validation that your architecture bypasses?
  3. What state does it assume? Does it assume initialized variables, specific contract states, or deployed dependencies that your fork doesn't guarantee?
  4. What chain properties does it assume? Block time, gas costs, precompile availability, opcode behavior?
  5. What economic assumptions does it make? Token liquidity, oracle availability, governance participation rates?

For SagaEVM, question #1 alone would have caught the bug: the Ethermint precompile assumed only the IBC module would call it, but in SagaEVM's architecture, arbitrary contracts could invoke the precompile directly.

Building a Continuous Fork Monitoring Pipeline

One-time audits aren't enough. Forks drift from upstream, and upstream discovers new vulnerabilities. Set up continuous monitoring:

# .github/workflows/fork-monitor.yml
name: Fork Upstream Monitor
on:
  schedule:
    - cron: '0 6 * * 1'  # Every Monday at 6 AM UTC
  workflow_dispatch:

jobs:
  check-upstream:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Fetch upstream changes
        run: |
          git remote add upstream https://github.com/evmos/ethermint.git || true
          git fetch upstream main

      - name: Check for security-relevant upstream commits
        run: |
          FORK_POINT=$(git merge-base HEAD upstream/main)
          SECURITY_COMMITS=$(git log --oneline $FORK_POINT..upstream/main \
            --grep="fix" --grep="vuln" --grep="security" --grep="CVE" \
            --grep="exploit" --grep="patch" --all-match -i | wc -l)

          if [ "$SECURITY_COMMITS" -gt 0 ]; then
            echo "⚠️ $SECURITY_COMMITS security-relevant upstream commits found!"
            git log --oneline $FORK_POINT..upstream/main \
              --grep="fix" --grep="vuln" --grep="security" --grep="CVE" \
              --grep="exploit" --grep="patch" --all-match -i
            # Send alert to Slack/Discord
          fi

      - name: Check upstream security advisories
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh api repos/evmos/ethermint/security-advisories \
            --jq '.[].summary' 2>/dev/null || echo "No advisories endpoint"
Enter fullscreen mode Exit fullscreen mode

Lessons from SagaEVM for Every Fork

  1. Precompiles are trust boundaries. When you inherit precompile code, you inherit its trust model. If your architecture changes who can call it, you've changed the security model.

  2. "Battle-tested" doesn't mean "tested in your battle." Ethermint's code was audited and deployed across multiple chains. But audits test the original deployment context, not yours.

  3. Supply-side validation ≠ demand-side validation. The Ethermint precompile validated message format but not message origin. Format validation is necessary but insufficient — you also need to validate who is submitting the message and why it should be trusted.

  4. Fork drift kills. Every day your fork drifts from upstream without tracking security patches, your risk increases. Multiple Ethermint-based chains are now vulnerable to variants of the same exploit because none of them backported upstream fixes.

  5. The most expensive audit is the one you skip. A fork-specific audit for SagaEVM would have cost $50,000-$100,000. The exploit cost $7 million. The math isn't hard.


The SagaEVM exploit wasn't novel — it was predictable. The vulnerability class (unvalidated cross-boundary message passing) is well-documented. The tools to detect it exist. The only thing missing was someone asking: "Does the code we inherited still work the way we think it does?" If you're shipping a fork, that question isn't optional.

Top comments (0)