Am 30. Juli 2023 zielte eine Reihe von Exploits auf mehrere Curve-Pools ab und führte zu Verlusten in Millionenhöhe. Es handelt sich um einen typischen Reentrancy-Angriff mit einer untypischen Ursache, die auf einen Compiler-Bug zurückzuführen ist, der zum Fehlen eines Reentrancy-Schutzes führt. Konkret gab es einen Fehler, bei dem die Reentrancy-Sperren für verschiedene Funktionen innerhalb eines Smart Contracts unterschiedlichen Speicher-Slots zugewiesen wurden. Infolgedessen waren Smart Contracts, die mit Vyper-Versionen 0.2.15, 0.2.16 und 0.3.0 kompiliert wurden, anfällig.
Hintergrund
Da Curve anstelle von Solidity für die Entwicklung seiner Smart Contracts Vyper verwendet, wird eine kurze Einführung in die Vyper-Sprache gegeben, um das Verständnis der damit verbundenen Schwachstelle zu erleichtern.
Vyper ist eine Python-basierte Programmiersprache, die von Vitalik Buterin, dem Mitbegründer von Ethereum, entwickelt wurde. Wie in seiner Dokumentation beschrieben, ist Vyper eine vertragsorientierte, domänenspezifische, pythonische Programmiersprache, die auf die Ethereum Virtual Machine (EVM) abzielt. Zu seinen Zielen gehören Einfachheit, "Pythonizität", Sicherheit und Auditing.
Vyper hat sich zur zweitbeliebtesten Programmiersprache für Ethereum und EVM-kompatible Ketten entwickelt, nach dem bekannten Solidity. Curve ist einer der größten Anwender der Vyper-Sprache, wobei die meisten seiner Verträge in dieser Sprache geschrieben sind. Viele Projekte, die sich auf Curve beziehen oder davon abgeleitet sind, verwenden ebenfalls Vyper, um eine bessere Code-Wiederverwendung und Interoperabilität mit Curve-Systemen zu gewährleisten.
Unten sehen Sie ein Code-Segment aus einem Curve-Pool (dem pETH/ETH-Pool, der bei diesem Vorfall angegriffen wurde). Obwohl die Syntax sehr ähnlich zu Python 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 ordnungsgemäß mit Typen versehen sein. Die Sprache unterstützt keine Klassen oder Vererbung; das Schlüsselwort 'self' wird ausschließlich verwendet, um auf den Vertrag selbst zu verweisen und auf Zustandsvariablen zuzugreifen. Eingebaute Dekoratoren (z. B. @external und @nonreentrant) werden verwendet, um die Eigenschaften einer Funktion zu kennzeichnen, und benutzerdefinierte Dekoratoren werden nicht unterstützt.
Schwachstellenanalyse
Die Schwachstelle ergibt sich aus einem Compiler-Bug, der zu einem Mangel an Reentrancy-Schutz führt.
Reentrancy-Angriffe sind eine der häufigsten Angriffsarten im Blockchain-Ökosystem. Insbesondere treten sie auf, wenn die Ausführung der Vertragslogik externe Aufrufe initiiert, von denen einige rekursiv in den ursprünglichen Vertrag zurückrufen können. Dies kann den Zwischenzustand des Vertrags während der Ausführung von Funktionen gefährlich für andere Verträge offenlegen, was potenziell zu Schwachstellen führen kann. Um dem entgegenzuwirken, wird ein Reentrancy-Schutz, eine Sperre, eingesetzt, um sicherzustellen, dass der Vertrag während der Ausführung einer einzelnen Transaktion nicht erneut aufgerufen werden kann.
Im zuvor erwähnten Code-Segment zeigt die @nonreentrant('lock')-Annotation an, dass die Funktion remove_liquidity mit der Reentrancy-Sperre namens lock geschützt werden soll. Zur weiteren Klarstellung könnte man dies mit dem OpenZeppelin ReentrancyGuard-Vertrag und seinem nonReentrant-Modifier vergleichen. Der Hauptunterschied bei Vyper besteht darin, dass Reentrancy-Sperren nicht von einer externen Bibliothek bereitgestellt werden, sondern eingebaute Funktionen der Sprache selbst sind. Dies schien ausreichend zu sein, bis eine eingehendere Untersuchung der Implementierung von Reentrancy-Sperren durchgeführt wurde. Es wurde festgestellt, dass der in Pull Request #2391 eingeführte Code (zusammengeführt am 23. Juli 2021) die Funktion set_storage_slots verwendete, um Speicher-Slots 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 für jede Reentrancy-Sperre der Speicher-Slot um 1 erhöht wird. Folglich erhalten verschiedene Funktionen eindeutige Speicher-Slots für ihre Reentrancy-Sperren. 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
Mit Vyper Compiler Version 0.2.16+commit.59e1bdd, der während der Code-Kompilierung generierte Vyper IR (Intermediate Representation) ist teilweise wie folgt aufgeführt:
[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 Zeilen 4-5 und 16-17, wo der generierte Code die Reentrancy-Sperre überprüft und den Sperrstatus im Speicher speichert. Es wurde jedoch festgestellt, dass verschiedene Funktionen unterschiedliche Slots für die nicht-rekursive Sperre verwendeten: test_funcA verwendet Slot 0, während test_funcB Slot 1 verwendet. Dies deutet darauf hin, dass die Reentrancy-Sperre unwirksam ist, da der Vertrag über verschiedene Funktionen wieder aufgerufen werden kann.
Angriffsanalyse
Hier geben wir einige Hintergründe zu Curve. Ein Curve-Pool ermöglicht es Benutzern, Liquidität über die Funktionen add_liquidity und remove_liquidity bereitzustellen und abzuziehen. Beim Hinzufügen von Liquidität wird der hinzuzufügende Betrag durch ein Verhältnis der Gesamtmenge bestimmt, insbesondere das Verhältnis der hinzugefügten Liquidität zur bestehenden Liquidität. Auf der anderen Seite berechnet remove_liquidity die Anzahl der abzuziehenden Token basierend auf dem Verhältnis der eingereichten LP-Token (Liquidity Provider) zur aktuellen Gesamtmenge, wonach die LP-Token verbrannt werden.
Darüber hinaus unterstützt Curve Pools, die native Token verarbeiten, und nutzt Low-Level-Aufrufe (die raw_call-Funktion in Vyper), um den nativen Token an den Benutzer zurückzugeben. Im unten stehenden Code-Segment berechnet die Funktion remove_liquidity zuerst die zu entnehmenden Token basierend auf der Menge der LP-Token und der Gesamtmenge und überträgt sie, und dann wird die Gesamtmenge anschließend reduziert.
Unter normalen Umständen wäre dies sicher, da die Reentrancy-Sperre die Offenlegung des Zwischenzustands während Low-Level-Aufrufen verhindern sollte. Wenn die Reentrancy-Sperre jedoch unwirksam ist – ein Fehler, der schließlich ausgenutzt wurde –, wird ein Angriff möglich. Die unwirksame Reentrancy-Sperre bedeutet, dass der Zwischenzustand (bei dem die abzuziehenden Token bereits übertragen wurden, die Gesamtmenge aber noch nicht reduziert wurde) während eines Low-Level-Aufrufs anfällig wird und ein erneutes Aufrufen des Vertrags 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 die Gesamtmenge noch nicht reduziert wurde, wie in Zeile 27 oben angegeben. Wenn wir zu diesem Zeitpunkt erneut in den Vertrag eintreten und add_liquidity aufrufen, würde die Bereitstellung von Liquidität auf einer falschen Gesamtmenge (höher als sie sein sollte) basieren, was zur Erstellung einer übermäßigen Anzahl von LP-Token führt und Verluste für den Pool zur Folge hat. 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.

Die Angriffspur ist sehr klar.
- Der Angreifer borgte einen Flash-Loan von Balancer 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 entzog dem Pool dann 3.740 pETH und 34.316 ETH, indem er 32.431,41 pETH/ETH-Pool-LP-Token verbrannte.
- Während der Liquiditätsentnahme wurde der Poolvertrag erneut aufgerufen. 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 die Gesamtmenge vor der Liquiditätsentnahme verwendet, was falsch war und dazu führte, dass mehr LP-Token geprägt wurden, als möglich sein sollte.
- Anschließend zog der Angreifer 1.184,73 pETH und 47.506,53 ETH ab, indem er 10.272,84 Curve LP-Token verbrannte. Zusammenfassend lässt sich sagen, dass der Angreifer Profit machte, indem er zusätzliche LP-Token prägte und den Pool mit diesen zusätzlichen LP-Token leerte.
Zusammenfassung
Diese Schwachstelle entstand aus dem Compiler und nicht aus dem Quellcode. Dies ist das erste Mal, dass ein Compiler-Bug zu erheblichen finanziellen Verlusten im Blockchain-Ökosystem 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. Compilerbezogene Probleme sind möglicherweise nicht sofort offensichtlich, können aber weitreichende und schwerwiegende Folgen haben. Die Sicherung von Compilern erfordert rigorose Bewertungen, die umfassende Audits und robuste Bug-Bounty-Programme beinhalten sollten, um Schwachstellen zu entdecken und zu beheben. Die inhärente Subtilität von Compiler-Bugs macht ihre Erkennung und Minderung komplex. Diese Komplexität unterstreicht die Bedeutung ausgeklügelter Angriffs-Erkennungs- und Präventionsmechanismen, wie sie von BlockSecs BlockSec Phalcon bereitgestellt werden, die wesentliche automatisierte Abwehrmaßnahmen bieten, um DeFi-Protokolle effektiv zu schützen.
Lesen Sie andere 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



