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.
- 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.
- 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.
- 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.
- 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.
Indeed, smart contracts compiled using Vyper versions 0.2.15, 0.2.16, and 0.3.0 are vulnerable, which can lead to the failure of the reentrancy guard. https://t.co/GM7Ze5to39 pic.twitter.com/K6Lo29Pfn2
— BlockSec (@BlockSecTeam) July 30, 2023
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:
- Lead-In: Top Ten "Awesome" Security Incidents in 2023
- #1: Harvesting MEV Bots by Exploiting Vulnerabilities in Flashbots Relay
- #2: Euler Finance Incident: The Largest Hack of 2023
- #3: KyberSwap Incident: Masterful Exploitation of Rounding Errors with Exceedingly Subtle Calculations
- #5: Platypus Finance: Surviving Three Attacks with a Stroke of Luck
- #6: Hundred Finance Incident: Catalyzing the Wave of Precision-Related Exploits in Vulnerable Forked Protocols
- #7: ParaSpace Incident: A Race Against Time to Thwart the Industry's Most Critical Attack Yet
- #8: SushiSwap Incident: A Clumsy Rescue Attempt Leads to a Series of Copycat Attacks
- #9: MEV Bot 0xd61492: From Predator to Prey in an Ingenious Exploit
- #10: ThirdWeb Incident: Incompatibility Between Trusted Modules Exposes Vulnerability



