Zum Hauptinhalt springen
7 Min. Lesezeit R.S.

Smart Contracts: Sicherheit als oberste Priorität

Ein Bug in einer Webanwendung ist ärgerlich. Ein Bug in einem Smart Contract kann Millionen kosten — und ist nach dem Deployment nicht mehr zu beheben. Die Immutabilität der Blockchain, die für Vertrauen sorgt, macht Fehler permanent. In diesem Beitrag zeigen wir die häufigsten Schwachstellen anhand von verwundbarem Code und dem jeweiligen Fix.

Reentrancy

Die wohl bekannteste Schwachstelle. Ein externer Call gibt die Kontrolle an den Aufrufer zurück, der die Funktion erneut betritt, bevor der State aktualisiert ist.

Verwundbar:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract VulnerableVault {
    mapping(address => uint256) public balances;

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");

        // BUG: Externer Call VOR State-Update
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        balances[msg.sender] = 0; // Zu spät!
    }
}

Fix — Checks-Effects-Interactions-Pattern:

contract SecureVault {
    mapping(address => uint256) public balances;

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");

        // State-Update VOR dem externen Call
        balances[msg.sender] = 0;

        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

Zusätzlich empfehlen wir den Einsatz eines Reentrancy Guards:

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

contract GuardedVault is ReentrancyGuard {
    mapping(address => uint256) public balances;

    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");
        balances[msg.sender] = 0;

        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

Zugriffskontrolle

Fehlende oder fehlerhafte Zugriffskontrolle ist der häufigste Grund für Exploits in der Praxis. Oft fehlt schlicht ein onlyOwner-Modifier an einer kritischen Funktion.

Verwundbar:

contract VulnerableToken {
    mapping(address => uint256) public balances;

    // BUG: Jeder kann sich beliebig viele Token minten
    function mint(address to, uint256 amount) external {
        balances[to] += amount;
    }
}

Fix mit OpenZeppelin AccessControl:

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

contract SecureToken is AccessControl {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    mapping(address => uint256) public balances;

    constructor() {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
    }

    function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
        balances[to] += amount;
    }
}

Für DAO-Contracts bevorzugen wir rollenbasierte Zugriffskontrolle gegenüber einfachem Ownable, da DAOs typischerweise mehrere Akteure mit unterschiedlichen Berechtigungen haben.

Integer Overflow (vor Solidity 0.8)

Seit Solidity 0.8 sind arithmetische Operationen standardmäßig vor Overflow geschützt. Trotzdem begegnen uns in Audits regelmäßig Contracts, die unchecked-Blöcke nutzen, um Gas zu sparen — und dabei die Sicherheit opfern:

// Nur sicher, wenn Sie BEWEISEN können, dass kein Overflow auftritt
function unsafeIncrement(uint256 x) internal pure returns (uint256) {
    unchecked { return x + 1; }  // OK: uint256 max ist astronomisch
}

// GEFÄHRLICH: Kann bei beliebigen Inputs überlaufen
function unsafeMultiply(uint256 a, uint256 b) internal pure returns (uint256) {
    unchecked { return a * b; }  // BUG: Overflow möglich
}

Regel: Verwenden Sie unchecked nur für Schleifenzähler und Inkrement-Operationen, bei denen der Overflow mathematisch ausgeschlossen ist.

Front-Running und Sandwich-Attacks

Jede Transaktion ist im Mempool sichtbar, bevor sie in einen Block aufgenommen wird. Ein Angreifer kann Ihre Transaktion sehen und eine eigene davor platzieren. Bei Governance-Votings kann das bedeuten, dass ein Angreifer Token kauft, abstimmt und direkt danach verkauft.

Gegenmaßnahmen:

// Commit-Reveal-Pattern für Abstimmungen
contract SecureVoting {
    mapping(bytes32 => bool) public commits;
    mapping(address => bytes32) public userCommits;

    // Phase 1: Commit (Hash des Votes)
    function commitVote(bytes32 hash) external {
        userCommits[msg.sender] = hash;
        commits[hash] = true;
    }

    // Phase 2: Reveal (Vote + Salt)
    function revealVote(uint256 proposalId, bool support, bytes32 salt)
        external
    {
        bytes32 hash = keccak256(
            abi.encodePacked(proposalId, support, salt, msg.sender)
        );
        require(userCommits[msg.sender] == hash, "Invalid reveal");
        delete userCommits[msg.sender];

        // Vote verarbeiten ...
    }
}

Audit-Prozess

Unsere interne Sicherheitsprüfung vor jedem Deployment folgt einem festen Schema:

1. Statische Analyse mit Slither:

$ slither src/Vault.sol --filter-paths "node_modules"
# Prüft auf bekannte Patterns: Reentrancy, ungenutzte
# Rückgabewerte, Shadowing, etc.

2. Unit Tests mit Foundry:

function test_ReentrancyProtection() public {
    AttackerContract attacker = new AttackerContract(address(vault));

    // Attacker deponiert und versucht Reentrancy
    vm.deal(address(attacker), 1 ether);
    attacker.deposit{value: 1 ether}();

    vm.expectRevert("ReentrancyGuard: reentrant call");
    attacker.attack();
}

3. Fuzzing mit Foundry:

function testFuzz_WithdrawNeverExceedsBalance(
    address user,
    uint256 depositAmount
) public {
    vm.assume(user != address(0));
    vm.assume(depositAmount > 0 && depositAmount <= 100 ether);

    vm.deal(user, depositAmount);
    vm.prank(user);
    vault.deposit{value: depositAmount}();

    uint256 vaultBalanceBefore = address(vault).balance;
    vm.prank(user);
    vault.withdraw();

    assertGe(vaultBalanceBefore, depositAmount);
}

4. Externer Audit — Für Contracts, die signifikante Werte verwalten, beauftragen wir externe Auditoren. Ein interner Review ersetzt keinen externen Audit, denn eigene Annahmen werden selten von den eigenen Entwicklern hinterfragt.

Checkliste

Abschließend unsere Kurzcheckliste für jedes Smart-Contract-Deployment:

  • Checks-Effects-Interactions eingehalten?
  • Reentrancy Guard bei allen externen Calls?
  • Zugriffskontrolle auf alle state-verändernden Funktionen?
  • unchecked nur wo mathematisch bewiesen sicher?
  • Slither ohne High/Medium-Findings?
  • 100 % Branch-Coverage in Tests?
  • Fuzz-Tests für alle öffentlichen Funktionen?
  • Externer Audit bei Wertverwahrung?

Smart-Contract-Sicherheit ist kein Feature, das man am Ende hinzufügt. Sie muss von der ersten Zeile Code an mitgedacht werden. Die Kosten eines Audits sind ein Bruchteil dessen, was ein Exploit kosten kann.