DEV Community

ohmygod
ohmygod

Posted on

The Custom Detector Arms Race: Building Slither, Aderyn, and Semgrep Rules That Catch the Vulnerability Patterns Default Scanners Miss

The Custom Detector Arms Race: Building Slither, Aderyn, and Semgrep Rules That Catch the Vulnerability Patterns Default Scanners Miss

Default static analysis rules catch roughly 30% of real-world DeFi exploit patterns. The other 70% — protocol-specific logic bugs, cross-contract composability flaws, and novel attack primitives — require custom detectors tuned to your codebase.

This guide walks through building custom security detectors for three tools that every serious audit pipeline should include: Slither (Trail of Bits), Aderyn (Cyfrin), and Semgrep (r2c). Each tool has distinct strengths, and custom rules unlock capabilities far beyond their default rulesets.

We'll build detectors for three vulnerability classes that caused $67M+ in Q1 2026 losses and that no default scanner catches.

Why Default Rules Aren't Enough

Consider the Q1 2026 exploit landscape:

Exploit Loss Root Cause Default Scanner Detection
Resolv Labs $25M Unbounded off-chain minting authority ❌ None
SwapNet $13.4M Arbitrary external call target ⚠️ Partial (Slither)
Venus Protocol $3.7M Supply cap bypass via illiquid collateral ❌ None
CrossCurve $3M Missing bridge gateway validation ❌ None
Moonwell $1.78M Oracle price component omission ❌ None

Default scanners excel at syntactic patterns — reentrancy guards, unchecked return values, uninitialized storage. They fail at semantic patterns — protocol-specific invariants, cross-function state dependencies, and economic logic flaws.

Detector 1: Unbounded Privileged Minting (Slither Custom Detector)

The Resolv $25M exploit succeeded because a single off-chain signer could mint unlimited tokens with no on-chain rate limiting. This pattern — a privileged function with no bounds checking — appears in roughly 40% of token contracts.

The Vulnerability Pattern

// VULNERABLE: No upper bound on privileged mint
function mint(address to, uint256 amount) external onlyMinter {
    _mint(to, amount);  // Minter can print infinite tokens
}
Enter fullscreen mode Exit fullscreen mode

Building the Slither Detector

"""Slither detector: Unbounded privileged minting functions."""
from slither.detectors.abstract_detector import (
    AbstractDetector,
    DetectorClassification,
)
from slither.core.declarations import Function
from slither.analyses.data_dependency.data_dependency import is_dependent

class UnboundedPrivilegedMint(AbstractDetector):
    ARGUMENT = "unbounded-mint"
    HELP = "Privileged minting function without supply bounds"
    IMPACT = DetectorClassification.HIGH
    CONFIDENCE = DetectorClassification.MEDIUM

    WIKI = "https://github.com/example/detectors/wiki/unbounded-mint"
    WIKI_TITLE = "Unbounded Privileged Minting"
    WIKI_DESCRIPTION = (
        "Detects minting functions restricted to privileged roles "
        "that lack supply cap or rate-limit checks."
    )
    WIKI_RECOMMENDATION = (
        "Add per-transaction mint caps, daily rate limits, "
        "and total supply ceiling checks."
    )

    # Mint-like function selectors
    MINT_SIGNATURES = {"mint", "mintTo", "issue", "create", "inflate"}

    # Access control modifiers that indicate privilege
    ACCESS_MODIFIERS = {
        "onlyOwner", "onlyMinter", "onlyAdmin", "onlyRole",
        "onlyOperator", "onlyGovernance", "requiresAuth",
    }

    def _has_access_control(self, func: Function) -> bool:
        """Check if function has access control modifiers."""
        modifier_names = {m.name for m in func.modifiers}
        return bool(modifier_names & self.ACCESS_MODIFIERS)

    def _calls_mint(self, func: Function) -> bool:
        """Check if function calls internal _mint or equivalent."""
        for internal in func.internal_calls:
            if hasattr(internal, "name") and internal.name in (
                "_mint", "_safeMint", "_issue"
            ):
                return True
        return False

    def _has_supply_check(self, func: Function) -> bool:
        """Check if function validates against a supply cap."""
        source = func.source_mapping.content if func.source_mapping else ""
        cap_keywords = [
            "maxSupply", "supplyCap", "MAX_SUPPLY", "totalSupply",
            "mintLimit", "dailyLimit", "rateLimi"
        ]
        return any(kw in source for kw in cap_keywords)

    def _detect(self):
        results = []
        for contract in self.compilation_unit.contracts_derived:
            for func in contract.functions:
                if (
                    func.name.lower() in self.MINT_SIGNATURES
                    and self._has_access_control(func)
                    and self._calls_mint(func)
                    and not self._has_supply_check(func)
                ):
                    info = [
                        func, " is a privileged minting function ",
                        "with no supply cap or rate limit.\n",
                        "\tContract: ", contract, "\n",
                        "\tRisk: Compromised key → unlimited minting ",
                        "(see Resolv $25M exploit, March 2026)\n",
                    ]
                    results.append(self.generate_result(info))
        return results
Enter fullscreen mode Exit fullscreen mode

Installation and Usage

# Save as slither_plugins/unbounded_mint.py
mkdir -p slither_plugins
# Run with plugin directory
slither . --detect unbounded-mint \
  --plugin-dir ./slither_plugins

# Or register as a pip package for CI
# setup.py with entry_points for slither_analyzer.plugin
Enter fullscreen mode Exit fullscreen mode

The Secure Pattern

contract SecureMintable is ERC20, AccessControl {
    uint256 public constant MAX_SUPPLY = 100_000_000e18;
    uint256 public constant DAILY_MINT_LIMIT = 1_000_000e18;

    mapping(uint256 => uint256) public dailyMinted; // day => amount

    function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
        uint256 today = block.timestamp / 1 days;

        require(totalSupply() + amount <= MAX_SUPPLY, "Exceeds max supply");
        require(dailyMinted[today] + amount <= DAILY_MINT_LIMIT, "Daily limit");

        dailyMinted[today] += amount;
        _mint(to, amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

Detector 2: Arbitrary External Call Targets (Aderyn Custom Detector)

The SwapNet $13.4M exploit used user-controlled call() targets to redirect approved token transfers. Slither's default arbitrary-send-eth catches ETH transfers but misses ERC-20 transferFrom exploitation via arbitrary targets.

Building the Aderyn Detector (Rust)

Aderyn detectors are Rust modules that implement the IssueDetector trait:

use std::collections::BTreeMap;
use std::error::Error;

use aderyn_core::{
    ast::NodeID,
    context::{
        browser::ExtractLowLevelCalls,
        workspace_context::WorkspaceContext,
    },
    detect::detector::{
        IssueDetector, IssueDetectorNamePool, IssueSeverity,
    },
};

#[derive(Default)]
pub struct ArbitraryCallTargetDetector {
    found_instances: BTreeMap<(String, usize, String), NodeID>,
}

impl IssueDetector for ArbitraryCallTargetDetector {
    fn detect(
        &mut self,
        context: &WorkspaceContext,
    ) -> Result<bool, Box<dyn Error>> {
        // Find all low-level calls in the codebase
        for func in context.function_definitions() {
            let low_level_calls = ExtractLowLevelCalls::from(func)
                .extracted;

            for call in &low_level_calls {
                let source = call.src.clone();

                // Check if the call target is derived from
                // a function parameter (user-controlled)
                if self.is_parameter_derived_target(context, func, call) {
                    // Check if there's no whitelist validation
                    if !self.has_target_whitelist(func) {
                        self.found_instances.insert(
                            (
                                source,
                                func.src_line,
                                format!(
                                    "Arbitrary call target in `{}` — \
                                     user controls .call() destination \
                                     with no whitelist",
                                    func.name
                                ),
                            ),
                            call.id,
                        );
                    }
                }
            }
        }

        Ok(!self.found_instances.is_empty())
    }

    fn severity(&self) -> IssueSeverity {
        IssueSeverity::High
    }

    fn title(&self) -> String {
        String::from(
            "User-controlled low-level call target without whitelist"
        )
    }

    fn description(&self) -> String {
        String::from(
            "A low-level .call() uses a target address derived from \
             function parameters without validating against an \
             approved whitelist. An attacker can redirect the call \
             to any contract, including token contracts, to execute \
             transferFrom() with the contract's existing approvals. \
             See: SwapNet $13.4M exploit (Jan 2026)."
        )
    }

    fn instances(&self) -> BTreeMap<(String, usize, String), NodeID> {
        self.found_instances.clone()
    }

    fn name(&self) -> String {
        IssueDetectorNamePool::ArbitraryCallTarget.to_string()
    }
}
Enter fullscreen mode Exit fullscreen mode

Equivalent Semgrep Rule (Simpler, Faster)

For teams that don't want to write Rust, Semgrep offers a YAML-based alternative:

rules:
  - id: arbitrary-call-target
    severity: ERROR
    message: >
      User-controlled low-level call target without whitelist 
      validation. An attacker can redirect .call() to token 
      contracts and drain approved balances.
      See: SwapNet $13.4M exploit (Jan 2026).
    languages: [solidity]
    metadata:
      category: security
      confidence: MEDIUM
      impact: HIGH
      references:
        - https://blocksec.com/swapnet-postmortem
    patterns:
      - pattern: |
          function $FUNC(..., address $TARGET, ...) ... {
            ...
            $TARGET.call{...}(...);
            ...
          }
      - pattern-not: |
          function $FUNC(..., address $TARGET, ...) ... {
            ...
            require(allowedTargets[$TARGET], ...);
            ...
            $TARGET.call{...}(...);
            ...
          }
      - pattern-not: |
          function $FUNC(..., address $TARGET, ...) ... {
            ...
            require($TARGET == $ROUTER, ...);
            ...
            $TARGET.call{...}(...);
            ...
          }
Enter fullscreen mode Exit fullscreen mode

Running the Semgrep Rule

# Single rule
semgrep --config ./rules/arbitrary-call-target.yaml \
  ./contracts/

# With all custom rules
semgrep --config ./rules/ ./contracts/ \
  --json --output results.json
Enter fullscreen mode Exit fullscreen mode

Detector 3: Oracle Price Component Omission (Semgrep)

The Moonwell $1.78M exploit happened because a compound oracle priced cbETH at $1.12 instead of ~$2,200 — the ETH/USD multiplication step was missing. This is a semantic bug that purely syntactic tools miss, but Semgrep's pattern matching can catch the structural signature.

The Vulnerability Pattern

// VULNERABLE: Returns ratio without base price multiplication
function getUnderlyingPrice(CToken cToken) external view returns (uint) {
    // cbETH/ETH ratio ≈ 1.04, but missing × ETH/USD ($2,150)
    uint ratio = IStakedToken(underlying).exchangeRate();
    return ratio;  // Returns ~1.04 instead of ~$2,236
}
Enter fullscreen mode Exit fullscreen mode

The Semgrep Detector

rules:
  - id: oracle-missing-base-price
    severity: ERROR
    message: >
      Oracle returns exchange rate / ratio without multiplying 
      by the base asset's USD price. Derivative tokens (stETH, 
      cbETH, rETH, sDAI) need: ratio × basePrice.
      See: Moonwell $1.78M exploit (Feb 2026).
    languages: [solidity]
    metadata:
      category: security
      confidence: MEDIUM
      impact: HIGH
    patterns:
      - pattern: |
          function $GET_PRICE(...) ... returns (uint ...) {
            ...
            uint $RATIO = $STAKED.exchangeRate();
            ...
            return $RATIO;
          }
      - pattern-not: |
          function $GET_PRICE(...) ... returns (uint ...) {
            ...
            uint $RATIO = $STAKED.exchangeRate();
            ...
            uint $BASE = $ORACLE.getPrice(...);
            ...
          }

  - id: oracle-missing-multiplication
    severity: WARNING 
    message: >
      Oracle function returns a single feed value for a 
      derivative/wrapped token without combining multiple 
      price sources. Verify the returned price represents 
      a fully-resolved USD value, not just a ratio.
    languages: [solidity]
    metadata:
      category: security
      confidence: LOW
      impact: HIGH
    pattern-either:
      - pattern: |
          function $FUNC(...) ... returns (uint ...) {
            ...
            $FEED.latestRoundData();
            ...
            return $SINGLE_VALUE;
          }
    pattern-not-inside: |
      function $FUNC(...) ... returns (uint ...) {
        ...
        $FEED1.latestRoundData();
        ...
        $FEED2.latestRoundData();
        ...
      }
Enter fullscreen mode Exit fullscreen mode

Combining All Three: The Multi-Tool CI Pipeline

Each tool has strengths — Slither for deep data-flow analysis, Aderyn for Rust-speed AST traversal, Semgrep for rapid pattern matching. Combining them catches more:

# .github/workflows/security-scan.yaml
name: Custom Security Detectors
on: [push, pull_request]

jobs:
  slither-custom:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: crytic/slither-action@v0.4.0
        with:
          slither-args: >
            --detect unbounded-mint,arbitrary-send-erc20
            --plugin-dir ./security/slither-plugins
            --json slither-results.json
      - uses: actions/upload-artifact@v4
        with:
          name: slither-results
          path: slither-results.json

  aderyn-custom:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Aderyn with custom detectors
        run: |
          aderyn . \
            --custom-detectors ./security/aderyn-detectors/ \
            --output aderyn-report.json
      - uses: actions/upload-artifact@v4
        with:
          name: aderyn-results
          path: aderyn-report.json

  semgrep-custom:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: returntocorp/semgrep-action@v1
        with:
          config: ./security/semgrep-rules/
      - name: Export results
        run: |
          semgrep --config ./security/semgrep-rules/ \
            ./contracts/ --json > semgrep-results.json
      - uses: actions/upload-artifact@v4
        with:
          name: semgrep-results
          path: semgrep-results.json

  merge-results:
    needs: [slither-custom, aderyn-custom, semgrep-custom]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
      - name: Merge and deduplicate findings
        run: |
          python3 security/merge_findings.py \
            slither-results/slither-results.json \
            aderyn-results/aderyn-report.json \
            semgrep-results/semgrep-results.json \
            --output merged-report.json
      - name: Fail on HIGH severity
        run: |
          HIGH_COUNT=$(jq '[.findings[] | select(.severity=="HIGH")] | length' merged-report.json)
          echo "HIGH severity findings: $HIGH_COUNT"
          if [ "$HIGH_COUNT" -gt 0 ]; then
            echo "::error::$HIGH_COUNT HIGH severity findings detected"
            jq '.findings[] | select(.severity=="HIGH")' merged-report.json
            exit 1
          fi
Enter fullscreen mode Exit fullscreen mode

The Findings Merger Script

#!/usr/bin/env python3
"""Merge and deduplicate findings from multiple security scanners."""
import json
import sys
from pathlib import Path

def normalize_slither(data: dict) -> list:
    findings = []
    for detector in data.get("results", {}).get("detectors", []):
        findings.append({
            "tool": "slither",
            "severity": detector.get("impact", "UNKNOWN").upper(),
            "title": detector.get("check", ""),
            "description": detector.get("description", ""),
            "locations": [
                e.get("source_mapping", {}).get("filename_short", "")
                for e in detector.get("elements", [])
            ],
        })
    return findings

def normalize_aderyn(data: dict) -> list:
    findings = []
    for issue in data.get("high_issues", {}).get("issues", []):
        findings.append({
            "tool": "aderyn",
            "severity": "HIGH",
            "title": issue.get("title", ""),
            "description": issue.get("description", ""),
            "locations": [
                inst.get("contract_path", "")
                for inst in issue.get("instances", [])
            ],
        })
    return findings

def normalize_semgrep(data: dict) -> list:
    findings = []
    for result in data.get("results", []):
        severity_map = {"ERROR": "HIGH", "WARNING": "MEDIUM", "INFO": "LOW"}
        findings.append({
            "tool": "semgrep",
            "severity": severity_map.get(
                result.get("extra", {}).get("severity", ""), "MEDIUM"
            ),
            "title": result.get("check_id", ""),
            "description": result.get("extra", {}).get("message", ""),
            "locations": [result.get("path", "")],
        })
    return findings

def deduplicate(findings: list) -> list:
    seen = set()
    unique = []
    for f in findings:
        key = (f["title"], tuple(sorted(f["locations"])))
        if key not in seen:
            seen.add(key)
            unique.append(f)
    return unique

if __name__ == "__main__":
    all_findings = []
    for path in sys.argv[1:-2]:  # Skip --output flag
        data = json.loads(Path(path).read_text())
        if "slither" in path:
            all_findings.extend(normalize_slither(data))
        elif "aderyn" in path:
            all_findings.extend(normalize_aderyn(data))
        elif "semgrep" in path:
            all_findings.extend(normalize_semgrep(data))

    output_path = sys.argv[-1]
    result = {
        "total": len(all_findings),
        "unique": len(deduplicate(all_findings)),
        "findings": deduplicate(all_findings),
    }
    Path(output_path).write_text(json.dumps(result, indent=2))
    print(f"Merged: {result['total']} total, {result['unique']} unique")
Enter fullscreen mode Exit fullscreen mode

Solana Equivalent: Custom Clippy Lints for Anchor Programs

Solana programs need custom detectors too. Here's a cargo-clippy-style lint for missing signer validation in Anchor CPI calls:

// Custom Anchor security check — run as a build script
// or integrate with cargo-audit-anchor
use std::fs;
use std::path::Path;
use regex::Regex;

fn check_cpi_signer_validation(source_dir: &Path) -> Vec<String> {
    let mut warnings = Vec::new();
    let cpi_pattern = Regex::new(
        r"invoke_signed?\s*\(\s*&([a-zA-Z_]+)"
    ).unwrap();
    let signer_check = Regex::new(
        r"constraint\s*=\s*.*\.key\(\)\s*==|has_one\s*="
    ).unwrap();

    for entry in walkdir::WalkDir::new(source_dir)
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|e| e.path().extension().map_or(false, |ext| ext == "rs"))
    {
        let content = fs::read_to_string(entry.path())
            .unwrap_or_default();

        for cap in cpi_pattern.captures_iter(&content) {
            let account_name = &cap[1];
            // Check if this account has signer validation
            // in the Accounts struct
            if !signer_check.is_match(&content) {
                warnings.push(format!(
                    "{}:{} — CPI call uses `{}` without \
                     explicit signer constraint. Add \
                     `constraint = {}.key() == expected.key()`",
                    entry.path().display(),
                    content[..cap.get(0).unwrap().start()]
                        .lines().count(),
                    account_name,
                    account_name,
                ));
            }
        }
    }
    warnings
}

fn main() {
    let warnings = check_cpi_signer_validation(
        Path::new("programs/")
    );
    for w in &warnings {
        eprintln!("⚠️  {}", w);
    }
    if !warnings.is_empty() {
        std::process::exit(1);
    }
}
Enter fullscreen mode Exit fullscreen mode

Detection Coverage Matrix

Vulnerability Class Slither Default Slither Custom Aderyn Custom Semgrep Custom
Unbounded privileged mint ⚠️ ⚠️
Arbitrary call target (ERC-20 drain) ⚠️
Oracle price component omission ⚠️
Missing supply cap validation ⚠️
Bridge gateway validation bypass ⚠️

✅ = Detected | ⚠️ = Partial | ❌ = Missed

Key insight: No single tool catches everything. The combination of Slither (deep data-flow), Aderyn (fast AST analysis), and Semgrep (pattern matching) provides ~85% coverage on custom rules versus ~30% with any tool's defaults alone.

7-Point Custom Detector Development Checklist

  1. Map your exploit database — Catalog every vulnerability class that has affected your protocol type (lending, DEX, bridge, etc.)
  2. Identify the syntactic signature — What does the vulnerable code pattern look like structurally?
  3. Choose the right tool — Slither for data-flow, Semgrep for structural patterns, Aderyn for AST-level analysis
  4. Build positive and negative test cases — Include both vulnerable and secure code in your test suite
  5. Tune false positive rate — Aim for <10% false positives; a noisy detector gets ignored
  6. Version control your detectors — Custom rules are code; they need the same CI/CD discipline
  7. Update after every new exploit — Every Q1 2026 incident should produce at least one new custom rule

What Comes Next

Custom detectors are a force multiplier, but they're reactive — you build them after seeing an exploit pattern. The frontier is generative security: using LLMs to propose new detectors based on your protocol's specific architecture. Tools like FLAMES and InvCon+ are already doing this for invariant properties.

The teams that combine custom static analysis rules with AI-generated invariants and fuzzing campaigns will catch the next Resolv-class exploit before it costs $25M — not after.


This article is part of the DeFi Security Deep Dives series. Follow for weekly analysis of real exploits, defense patterns, and audit tooling.

Top comments (0)