Back to Blog

Nr. 4: Kurven-Vorfall: Compilerfehler erzeugt fehlerhaften Bytecode aus harmlosem Quellcode

Code Auditing
February 14, 2024
10 min read

Am 30. Juli 2023 zielte eine Reihe von Exploits auf mehrere Curve-Pools ab, was zu Verlusten in Millionenhöhe führte. Es handelt sich um einen typischen Reentrancy-Angriff (Wiedereintrittsangriff) mit einer untypischen Ursache, die auf einen Compiler-Fehler zurückzuführen ist, der zum Fehlen des Reentrancy-Schutzes führt. Konkret gab es einen Fehler, bei dem die Reentrancy-Locks für verschiedene Funktionen innerhalb eines Smart Contracts unterschiedlichen Speicherplätzen zugewiesen wurden. Infolgedessen waren Smart Contracts, die mit den Vyper-Versionen 0.2.15, 0.2.16 und 0.3.0 kompiliert wurden, anfällig.

Hintergrund

Da Curve für die Entwicklung seiner Smart Contracts Vyper anstelle von Solidity verwendet, erfolgt hier eine kurze Einführung in die Vyper-Sprache, um das Verständnis der damit verbundenen Schwachstelle zu erleichtern.

Vyper ist eine auf Python basierende Programmiersprache, die von Vitalik Buterin, dem Mitbegründer von Ethereum, entwickelt wurde. Wie in der Dokumentation beschrieben, ist Vyper eine vertragsorientierte, fachspezifische, „pythonische“ Programmiersprache, die auf die Ethereum Virtual Machine (EVM) ausgerichtet ist. Ihre Ziele sind Einfachheit, „Pythonizität“, Sicherheit und Prüfbarkeit.

Vyper hat sich nach der bekannten Sprache Solidity zur am zweithäufigsten verwendeten Programmiersprache für Ethereum und EVM-kompatible Chains entwickelt. Curve ist einer der größten Anwender der Vyper-Sprache; der Großteil ihrer Verträge wurde damit geschrieben. Viele Projekte, die mit Curve in Verbindung stehen oder von Curve geforkt wurden, verwenden ebenfalls Vyper, um eine bessere Wiederverwendbarkeit des Codes und Interoperabilität mit Curve-Systemen zu gewährleisten.

Unten sehen Sie einen Code-Ausschnitt aus einem Curve-Pool (dem pETH/ETH-Pool, der bei diesem Vorfall angegriffen wurde). Obwohl die Syntax der von Python sehr ähnlich ist, gibt es bemerkenswerte Unterschiede zwischen Vyper und Python:

@external
@nonreentrant('lock')
def remove_liquidity(
    _burn_amount: uint256,
    _min_amounts: uint256[N_COINS],
    _receiver: address = msg.sender
) -> uint256[N_COINS]:
    """
    @notice Withdraw coins from the pool
    @dev Withdrawal amounts are based on current deposit ratios
    @param _burn_amount Quantity of LP tokens to burn in the withdrawal
    @param _min_amounts Minimum amounts of underlying coins to receive
    @param _receiver Address that receives the withdrawn coins
    @return List of amounts of coins that were withdrawn
    """
    total_supply: uint256 = self.totalSupply
    amounts: uint256[N_COINS] = empty(uint256[N_COINS])

    for i in range(N_COINS):
        old_balance: uint256 = self.balances[i]
        value: uint256 = old_balance * _burn_amount / total_supply
        assert value >= _min_amounts[i], "Withdrawal resulted in fewer coins than expected"
        self.balances[i] = old_balance - value
        amounts[i] = value

        if i == 0:
            raw_call(_receiver, b"", value=value)

Alle Parameter und Rückgabewerte einer Funktion innerhalb des Vertrags müssen korrekt typisiert sein. Die Sprache unterstützt keine Klassen oder Vererbung; das Schlüsselwort ‚self‘ wird ausschließlich verwendet, um sich auf den Vertrag selbst zu beziehen und auf Zustandsvariablen zuzugreifen. Eingebaute Decorator (z. B. @external und @nonreentrant) werden verwendet, um die Eigenschaften einer Funktion zu kennzeichnen; benutzerdefinierte Decorator werden nicht unterstützt.

Schwachstellenanalyse

Die Schwachstelle entsteht durch einen Compiler-Fehler, der zu einem Mangel an Reentrancy-Schutz führt.

Reentrancy-Angriffe sind eine der häufigsten Angriffsarten innerhalb des Blockchain-Ökosystems. Sie treten insbesondere dann auf, wenn die Ausführung einer Vertragslogik externe Aufrufe initiiert, von denen einige den ursprünglichen Vertrag rekursiv zurückrufen können. Dies kann den Zwischenzustand des Vertrags während der Funktionsausführung gefährlich anderen Verträgen preisgeben und potenziell zu Schwachstellen führen. Um dem entgegenzuwirken, wird ein Reentrancy-Schutz (oder Lock) eingesetzt, um sicherzustellen, dass während der Ausführung einer einzelnen Transaktion nicht erneut in den Vertrag eingetreten werden kann.

In dem zuvor genannten Code-Segment zeigt die Annotation @nonreentrant('lock') an, dass die Funktion remove_liquidity mit dem Reentrancy-Lock namens „lock“ gesichert werden soll. Zur weiteren Veranschaulichung könnte man dies mit dem OpenZeppelin-Vertrag „ReentrancyGuard“ und dessen Modifikator „nonReentrant“ vergleichen. Der Hauptunterschied in Vyper besteht darin, dass Reentrancy-Locks nicht von einer externen Bibliothek bereitgestellt werden, sondern integrierte Funktionen der Sprache selbst sind. Dies schien bis zu einer tiefergehenden Untersuchung der Implementierung der Reentrancy-Locks zufriedenstellend. Es wurde festgestellt, dass das im Pull Request #2391 (zusammengeführt am 23. Juli 2021) eingeführte Code-Segment die Funktion „set_storage_slots“ verwendete, um Speicherplätze basierend auf dem AST (Abstract Syntax Tree) des Vyper-Quellcodes zuzuweisen.

def set_storage_slots(vyper_module: vy_ast.Module) -> None:
    """
    Parse module-level Vyper AST to calculate the layout of storage variables.
    """
    # Allocate storage slots from 0
    # note storage is word-addressable, not byte-addressable
    storage_slot = 0

    for node in vyper_module.get_children(vy_ast.FunctionDef):
        type_ = node._metadata["type"]
        if type_.nonreentrant is not None:
            type_.set_reentrancy_key_position(StorageSlot(storage_slot))
            # TODO use one byte - or bit - per reentrancy key
            # requires either an extra SLOAD or caching the value of the
            # location in memory at entrance
            storage_slot += 1

Ein kritischer Fehler liegt jedoch darin, dass der Speicherplatz für jeden Reentrancy-Lock um 1 erhöht wird. Folglich werden verschiedenen Funktionen eindeutige Speicherplätze für ihre jeweiligen Reentrancy-Locks zugewiesen. Um diese Implementierung zu bewerten, kompilieren wir den folgenden Vertrag:

# Compile command: vyper -f bytecode,bytecode_runtime,ir test_contract.vy

from vyper.interfaces import ERC20

test_addr: address

@external
def __init__(_addr: address):
    self.test_addr = _addr

@external
@nonreentrant('lock')
def test_funcA():
    pass

@external
@nonreentrant('lock')
def test_funcB():
    pass

Unter Verwendung der Vyper-Compiler-Version 0.2.16+commit.59e1bdd wird die während der Code-Kompilierung generierte Vyper IR (Intermediate Representation) teilweise wie folgt aufgelistet:

[if,
  [eq, _func_sig, 2354224227 <test_funcA()>],
  [seq,
    [assert, [iszero, [sload, 0]]],
    [sstore, 0, 1],
    pass,
    # Line 13
    pass,
    # Line 12
    [sstore, 0, 0],
    stop]],
# Line 17
[if,
  [eq, _func_sig, 741100118 <test_funcB()>],
  [seq,
    [assert, [iszero, [sload, 1]]],
    [sstore, 1, 1],

Konzentrieren wir uns auf die IR in den Zeilen 4-5 und 16-17, wo der generierte Code den Reentrancy-Lock überprüft und den Lock-Zustand im Speicher ablegt. Es wurde jedoch beobachtet, dass verschiedene Funktionen unterschiedliche Slots für den Non-Reentrancy-Lock verwendeten: test_funcA belegt Slot 0, während test_funcB Slot 1 belegt. Dies deutet darauf hin, dass der Reentrancy-Lock ineffektiv ist, da der Vertrag durch verschiedene Funktionen erneut betreten werden kann.

Angriffsanalyse

Hier geben wir einige Kontextinformationen zu Curve. Ein Curve-Pool ermöglicht es Benutzern, Liquidität über die Funktionen „add_liquidity“ und „remove_liquidity“ bereitzustellen und abzuheben. Beim Hinzufügen von Liquidität wird die hinzuzufügende Menge durch ein Verhältnis des Gesamtangebots bestimmt, insbesondere durch den Anteil der hinzugefügten Liquidität zur vorhandenen Liquidität. Andererseits berechnet „remove_liquidity“ die Anzahl der abzuhebenden Token basierend auf dem Verhältnis der eingereichten LP (Liquidity Provider)-Token zum aktuellen Gesamtangebot, wonach die LP-Token verbrannt werden.

Darüber hinaus unterstützt Curve Pools, die native Token verarbeiten, und verwendet Low-Level-Aufrufe (die Funktion „raw_call“ in Vyper), um den nativen Token an den Benutzer zurückzugeben. In dem unten stehenden Code-Segment berechnet die Funktion „remove_liquidity“ zunächst die zu entfernenden Token basierend auf der LP-Token-Menge und dem Gesamtangebot und überträgt diese, wonach das Gesamtangebot anschließend verringert wird.

Unter normalen Umständen wäre dies sicher, da der Reentrancy-Lock die Offenlegung des Zwischenzustands während Raw-Aufrufen verhindern sollte. Wenn jedoch der Reentrancy-Lock ineffektiv ist – ein Fehler, der schließlich ausgenutzt wurde –, wird ein Angriff möglich. Der ineffektive Reentrancy-Lock bedeutet, dass der Zwischenzustand (bei dem die zu entfernenden Token bereits übertragen wurden, das Gesamtangebot aber noch nicht reduziert wurde) während eines Low-Level-Aufrufs anfällig wird, was einen erneuten Eintritt (Reentry) in den Vertrag ermöglicht.

@external
@nonreentrant('lock')
def remove_liquidity(
    _burn_amount: uint256,
    _min_amounts: uint256[N_COINS],
    _receiver: address = msg.sender
) -> uint256[N_COINS]:
    """
    @notice Withdraw coins from the pool
    @dev Withdrawal amounts are based on current deposit ratios
    @param _burn_amount Quantity of LP tokens to burn in the withdrawal
    @param _min_amounts Minimum amounts of underlying coins to receive
    @param _receiver Address that receives the withdrawn coins
    @return List of amounts of coins that were withdrawn
    """
    total_supply: uint256 = self.totalSupply
    amounts: uint256[N_COINS] = empty(uint256[N_COINS])

    for i in range(N_COINS):
        old_balance: uint256 = self.balances[i]
        value: uint256 = old_balance * _burn_amount / total_supply
        assert value >= _min_amounts[i], "Withdrawal resulted in fewer coins than expected"
        self.balances[i] = old_balance - value
        amounts[i] = value

        if i == 0:
            raw_call(_receiver, b"", value=value)
        else:
            response: Bytes[32] = raw_call(
                self.coins[1],
                concat(
                    method_id("transfer(address,uint256)"),
                    convert(_receiver, bytes32),
                    convert(value, bytes32),
                ),
                max_outsize=32,
            )
            if len(response) > 0:
                assert convert(response, bool)

    total_supply -= _burn_amount
    self.balanceOf[msg.sender] -= _burn_amount
    self.totalSupply = total_supply
    log Transfer(msg.sender, ZERO_ADDRESS, _burn_amount)

    log RemoveLiquidity(msg.sender, amounts, empty(uint256[N_COINS]), total_supply)

    return amounts

Diese Reentrancy kann ausgenutzt werden, da das Gesamtangebot nicht reduziert wurde, wie in Zeile 27 oben angegeben. Wenn wir an diesem Punkt erneut in den Vertrag eintreten und „add_liquidity“ aufrufen, würde die Bereitstellung von Liquidität auf einem falschen (höheren als tatsächlichen) Gesamtangebot basieren, was dazu führt, dass eine übermäßige Anzahl an LP-Token geprägt wird und Verluste für den Pool entstehen. Die meisten Angriffe bei diesem Vorfall nutzten diese Schwachstelle aus. Die folgende Diskussion untersucht eine der größten Angriffstransaktionen, 0xa84aa065ce, gegen den Curve pETH-ETH-Pool.

Der Angriffspfad ist sehr klar.

  1. Der Angreifer nahm einen Flash-Loan von Balancer auf und stellte dann 40.000 ETH als Liquidität für den Curve pETH/ETH-Pool bereit, wofür er 32.431,41 pETH-ETH LP-Token erhielt.
  2. Der Angreifer entfernte dann 3.740 pETH und 34.316 ETH aus dem Pool, indem er 32.431,41 pETH/ETH-Pool-LP-Token verbrannte.
  3. Während der Liquiditätsentnahme wurde der Pool-Vertrag erneut betreten. Innerhalb der Fallback-Funktion stellte der Angreifer weitere 40.000 ETH als Liquidität für den Curve pETH/ETH-Pool bereit und prägte zusätzliche 82.182 LP-Token. Während dieses Vorgangs wurde der Wert für das Gesamtangebot verwendet, der vor der Liquiditätsentnahme galt, was inkorrekt war und zur Prägung einer größeren Anzahl von LP-Token führte, als möglich sein sollte.
  4. Anschließend hob der Angreifer 1.184,73 pETH und 47.506,53 ETH ab, indem er 10.272,84 Curve LP-Token verbrannte. Zusammenfassend profitierte der Angreifer davon, zusätzliche LP-Token zu prägen und den Pool mithilfe dieser zusätzlichen LP-Token zu leeren.

Zusammenfassung

Diese Schwachstelle stammte vom Compiler, nicht vom Quellcode. Dies ist das erste Mal, dass ein Compiler-Fehler zu einem erheblichen finanziellen Verlust innerhalb des Blockchain-Ökosystems geführt hat.

Da Compiler eine kritische Infrastrukturkomponente darstellen, ist ihre Sicherheit für die Integrität und Funktionalität der Blockchain-Technologie von größter Bedeutung. Compiler-bezogene Probleme sind möglicherweise nicht sofort offensichtlich, können jedoch weitreichende und schwerwiegende Folgen haben. Die Absicherung von Compilern erfordert rigorose Bewertungen, die umfassende Audits und robuste Bug-Bounty-Programme umfassen sollten, um Schwachstellen aufzudecken und zu beheben. Die inhärente Subtilität von Compiler-Fehlern macht ihre Erkennung und Eindämmung komplex. Diese Komplexität unterstreicht die Bedeutung ausgefeilter Mechanismen zur Angriffserkennung und -prävention, wie sie etwa das „BlockSec Phalcon“ von BlockSec bietet, das wichtige automatisierte Abwehrmaßnahmen zum effektiven Schutz von DeFi-Protokollen liefert.

Lesen Sie weitere Artikel in dieser Serie:

Sign up for the latest updates
~$104.6M Lost: Verus, RetoSwap & More | BlockSec Weekly
Security Insights

~$104.6M Lost: Verus, RetoSwap & More | BlockSec Weekly

This BlockSec weekly security report covers 5 notable attack incidents identified between May 18 and May 24, 2026, with total estimated losses of approximately $104.6M. Two incidents are analyzed in detail: the highlighted $11.7M Verus-Ethereum Bridge exploit, where a type-validation failure allowed a handcrafted supplemental export output to be misclassified as a valid primary export; and the $2.7M RetoSwap exploit on Monero, where a protocol-level authentication flaw in the P2P trade flow allowed an attacker to hijack the arbitrator role via a forged ACK message. Three additional key compromise incidents (EchoProtocol, Polymarket, StablR) accounted for ~$90.2M.

~$4.72M Lost: TAC, Transit Finance & More | BlockSec Weekly
Security Insights

~$4.72M Lost: TAC, Transit Finance & More | BlockSec Weekly

This BlockSec weekly security report covers 3 notable attack incidents identified between May 11 and May 17, 2026, across TRON, TON, and Ethereum, with total estimated losses of approximately $4.72M. Three incidents are analyzed in detail: the highlighted $1.88M Transit Finance exploit on TRON, where a deprecated swap bridge contract with lingering token approvals was exploited through arbitrary calldata forwarding; the $2.8M TAC TON-to-EVM bridge exploit caused by missing canonical wallet verification in the jetton deposit flow; and the $46.75K Boost Hook exploit on Ethereum, where spot price manipulation on a Uniswap V4 hook-based perpetual protocol forced the protocol to buy tokens at inflated prices using its own reserves.

~$15.9M Lost: Trusted Volumes, Wasabi & More | BlockSec Weekly
Security Insights

~$15.9M Lost: Trusted Volumes, Wasabi & More | BlockSec Weekly

This BlockSec bi-weekly security report covers 11 notable attack incidents identified between April 27 and May 10, 2026, across Sui, Ethereum, BNB Chain, Base, Blast, and Berachain, with total estimated losses of approximately $15.9M. Three incidents are analyzed in detail: the highlighted $1.14M Aftermath Finance exploit on Sui, where a signed/unsigned semantic mismatch in the builder-fee validation allowed an attacker to inject a negative fee that was converted into positive collateral during settlement; the $5.87M Trusted Volumes RFQ authorization mismatch on Ethereum; and the $5.7M Wasabi Protocol infrastructure-to-contract-control compromise across multiple EVM chains.

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit