Ana içeriğe atla

Bir Sözleşmeye Tersine Mühendislik Uygulamak

evm
işlem kodları
İleri
Ori Pomerantz
30 Aralık 2021
28 dakikalık okuma

Giriş

Blokzincirde sır yoktur, gerçekleşen her şey tutarlı, doğrulanabilir ve herkese açıktır. İdeal olarak, sözleşmelerin kaynak kodları Etherscan üzerinde yayınlanmalı ve doğrulanmalıdır (opens in a new tab). Ancak, durum her zaman böyle değildir (opens in a new tab). Bu makalede, kaynak kodu olmayan bir sözleşmeye, 0x2510c039cc3b061d79e564b38836da87e31b342f (opens in a new tab), bakarak sözleşmelere nasıl tersine mühendislik uygulayacağınızı öğreneceksiniz.

Tersine derleyiciler vardır, ancak her zaman kullanılabilir sonuçlar (opens in a new tab) üretmezler. Bu makalede, bir sözleşmeyi işlem kodlarından (opens in a new tab) manuel olarak nasıl tersine mühendislik uygulayıp anlayacağınızı ve bir tersine derleyicinin sonuçlarını nasıl yorumlayacağınızı öğreneceksiniz.

Bu makaleyi anlayabilmek için EVM'nin temellerini zaten biliyor olmalı ve EVM assembly'sine en azından biraz aşina olmalısınız. Bu konular hakkında buradan bilgi edinebilirsiniz (opens in a new tab).

Çalıştırılabilir Kodu Hazırlama

Sözleşme için Etherscan'e gidip Sözleşme sekmesine ve ardından İşlem Kodları Görünümüne Geç'e tıklayarak işlem kodlarını alabilirsiniz. Her satırda bir işlem kodu olan bir görünüm elde edersiniz.

Opcode View from Etherscan

Ancak atlamaları anlayabilmek için her bir işlem kodunun kodun neresinde bulunduğunu bilmeniz gerekir. Bunu yapmanın bir yolu, bir Google E-Tablosu açmak ve işlem kodlarını C sütununa yapıştırmaktır. Önceden hazırlanmış bu e-tablonun bir kopyasını oluşturarak aşağıdaki adımları atlayabilirsiniz (opens in a new tab).

Sonraki adım, atlamaları anlayabilmemiz için doğru kod konumlarını elde etmektir. İşlem kodu boyutunu B sütununa ve konumu (onaltılık sistemde) A sütununa koyacağız. Bu işlevi B1 hücresine yazın ve ardından kodun sonuna kadar B sütununun geri kalanı için kopyalayıp yapıştırın. Bunu yaptıktan sonra B sütununu gizleyebilirsiniz.

=1+IF(REGEXMATCH(C1,"PUSH"),REGEXEXTRACT(C1,"PUSH(\d+)"),0)

İlk olarak bu işlev, işlem kodunun kendisi için bir bayt ekler ve ardından PUSH arar. Push işlem kodları özeldir çünkü yığına eklenen değer için ek baytlara sahip olmaları gerekir. Eğer işlem kodu bir PUSH ise, bayt sayısını çıkarır ve bunu ekleriz.

A1 hücresine ilk ofseti, yani sıfırı koyun. Ardından, A2 hücresine bu işlevi koyun ve yine A sütununun geri kalanı için kopyalayıp yapıştırın:

=dec2hex(hex2dec(A1)+B1)

Bu işlevin bize onaltılık değeri vermesine ihtiyacımız var çünkü atlamalardan önce yığına eklenen değerler (JUMP ve JUMPI) bize onaltılık sistemde verilir.

Giriş Noktası (0x00)

Sözleşmeler her zaman ilk bayttan itibaren yürütülür. Bu, kodun başlangıç kısmıdır:

Ofsetİşlem koduYığın (işlem kodundan sonra)
0PUSH1 0x800x80
2PUSH1 0x400x40, 0x80
4MSTOREBoş
5PUSH1 0x040x04
7CALLDATASIZECALLDATASIZE 0x04
8LTCALLDATASIZE<4
9PUSH2 0x005e0x5E CALLDATASIZE<4
CJUMPIBoş

Bu kod iki şey yapar:

  1. 0x40-0x5F bellek konumlarına 32 baytlık bir değer olarak 0x80 yazar (0x80, 0x5F'de depolanır ve 0x40-0x5E'nin tamamı sıfırdır).
  2. Çağrı verisi boyutunu okur. Normalde bir Ethereum sözleşmesi için çağrı verisi, işlev seçici için en az dört bayt gerektiren ABI'yi (uygulama ikili arayüzü) (opens in a new tab) izler. Çağrı verisi boyutu dörtten küçükse, 0x5E'ye atlar.

Flowchart for this portion

0x5E'deki İşleyici (ABI olmayan çağrı verisi için)

Ofsetİşlem kodu
5EJUMPDEST
5FCALLDATASIZE
60PUSH2 0x007c
63JUMPI

Bu kod parçacığı bir JUMPDEST ile başlar. EVM (Ethereum sanal makinesi) programları, JUMPDEST olmayan bir işlem koduna atlarsanız bir istisna fırlatır. Ardından CALLDATASIZE'a bakar ve eğer "doğru" ise (yani sıfır değilse) 0x7C'ye atlar. Buna aşağıda değineceğiz.

Ofsetİşlem koduYığın (işlem kodundan sonra)
64CALLVALUEÇağrı tarafından sağlanan . Solidity'de msg.value olarak adlandırılır
65PUSH1 0x066 CALLVALUE
67PUSH1 0x000 6 CALLVALUE
69DUP3CALLVALUE 0 6 CALLVALUE
6ADUP36 CALLVALUE 0 6 CALLVALUE
6BSLOADStorage[6] CALLVALUE 0 6 CALLVALUE

Yani çağrı verisi olmadığında Storage[6] değerini okuruz. Bu değerin ne olduğunu henüz bilmiyoruz, ancak sözleşmenin çağrı verisi olmadan aldığı işlemleri arayabiliriz. Herhangi bir çağrı verisi (ve dolayısıyla hiçbir yöntem) olmadan sadece ETH transfer eden işlemler Etherscan'de Transfer yöntemine sahiptir. Aslında, sözleşmenin aldığı ilk işlem (opens in a new tab) bir transferdir.

O işleme bakıp Click to see More (Daha Fazlasını Görmek İçin Tıklayın) seçeneğine tıklarsak, girdi verisi olarak adlandırılan çağrı verisinin gerçekten boş olduğunu (0x) görürüz. Ayrıca değerin 1.559 ETH olduğuna dikkat edin, bu daha sonra önemli olacaktır.

The call data is empty

Ardından, State (Durum) sekmesine tıklayın ve tersine mühendislik yaptığımız sözleşmeyi (0x2510...) genişletin. İşlem sırasında Storage[6]'nın değiştiğini görebilirsiniz ve Hex'i Number (Sayı) olarak değiştirirseniz, wei cinsinden transfer edilen değere (netlik için virgülleri ben ekledim) karşılık gelen 1.559.000.000.000.000.000 olduğunu görürsünüz, bu da bir sonraki sözleşme değerine karşılık gelir.

Storage[6]'daki değişiklik

Aynı dönemdeki diğer Transfer işlemlerinin (opens in a new tab) neden olduğu durum değişikliklerine bakarsak, Storage[6]'nın bir süre sözleşmenin değerini izlediğini görürüz. Şimdilik buna Value* diyeceğiz. Yıldız işareti (*) bize bu değişkenin ne yaptığını henüz bilmediğimizi hatırlatır, ancak sadece sözleşme değerini izlemek için olamaz çünkü hesap bakiyenizi ADDRESS BALANCE kullanarak alabiliyorken çok pahalı olan depolamayı kullanmaya gerek yoktur. İlk işlem kodu sözleşmenin kendi adresini iter. İkincisi, yığının en üstündeki adresi okur ve onu o adresin bakiyesiyle değiştirir.

Ofsetİşlem koduYığın
6CPUSH2 0x00750x75 Value* CALLVALUE 0 6 CALLVALUE
6FSWAP2CALLVALUE Value* 0x75 0 6 CALLVALUE
70SWAP1Value* CALLVALUE 0x75 0 6 CALLVALUE
71PUSH2 0x01a70x01A7 Value* CALLVALUE 0x75 0 6 CALLVALUE
74JUMP

Bu kodu atlama hedefinde izlemeye devam edeceğiz.

Ofsetİşlem koduYığın
1A7JUMPDESTValue* CALLVALUE 0x75 0 6 CALLVALUE
1A8PUSH1 0x000x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1AADUP3CALLVALUE 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1ABNOT2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE

NOT bitseldir, bu nedenle çağrı değerindeki her bitin değerini tersine çevirir.

Ofsetİşlem koduYığın
1ACDUP3Value* 2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1ADGTValue*>2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1AEISZEROValue*<=2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1AFPUSH2 0x01df0x01DF Value*<=2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1B2JUMPI

Value*, 2^256-CALLVALUE-1'den küçük veya ona eşitse atlarız. Bu, taşmayı önlemeye yönelik bir mantık gibi görünüyor. Ve gerçekten de, birkaç anlamsız işlemden sonra (örneğin belleğe yazma işleminin silinmek üzere olması) 0x01DE ofsetinde, taşma tespit edilirse sözleşmenin geri alındığını görüyoruz, ki bu normal bir davranıştır.

Böyle bir taşmanın son derece düşük bir ihtimal olduğunu unutmayın, çünkü çağrı değeri artı Value*'ın 2^256 wei'ye, yani yaklaşık 10^59 ETH'ye kıyaslanabilir olmasını gerektirir. Yazının yazıldığı sırada toplam ETH arzı iki yüz milyondan azdır (opens in a new tab).

Ofsetİşlem koduYığın
1DFJUMPDEST0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1E0POPValue* CALLVALUE 0x75 0 6 CALLVALUE
1E1ADDValue*+CALLVALUE 0x75 0 6 CALLVALUE
1E2SWAP10x75 Value*+CALLVALUE 0 6 CALLVALUE
1E3JUMP

Buraya ulaştıysak, Value* + CALLVALUE'i alın ve 0x75 ofsetine atlayın.

Ofsetİşlem koduYığın
75JUMPDESTValue*+CALLVALUE 0 6 CALLVALUE
76SWAP10 Value*+CALLVALUE 6 CALLVALUE
77SWAP26 Value*+CALLVALUE 0 CALLVALUE
78SSTORE0 CALLVALUE

Buraya ulaşırsak (ki bu çağrı verisinin boş olmasını gerektirir), Value*'a çağrı değerini ekleriz. Bu, Transfer işlemlerinin yaptığını söylediğimiz şeyle tutarlıdır.

Ofsetİşlem kodu
79POP
7APOP
7BSTOP

Son olarak, yığını temizleyin (ki bu gerekli değildir) ve işlemin başarılı bir şekilde sona erdiğini bildirin.

Özetlemek gerekirse, işte başlangıç kodu için bir akış şeması.

Entry point flowchart

0x7C'deki İşleyici

Bu işleyicinin ne yaptığını bilerek başlığa koymadım. Amaç size bu belirli sözleşmenin nasıl çalıştığını öğretmek değil, sözleşmelerde nasıl tersine mühendislik yapılacağını öğretmektir. Ne yaptığını benim yaptığım gibi, kodu takip ederek öğreneceksiniz.

Buraya birkaç yerden geliyoruz:

  • 1, 2 veya 3 baytlık çağrı verisi varsa (0x63 ofsetinden)
  • Yöntem imzası bilinmiyorsa (0x42 ve 0x5D ofsetlerinden)
Ofsetİşlem KoduYığın
7CJUMPDEST
7DPUSH1 0x000x00
7FPUSH2 0x009d0x9D 0x00
82PUSH1 0x030x03 0x9D 0x00
84SLOADStorage[3] 0x9D 0x00

Bu başka bir depolama hücresidir, hiçbir işlemde bulamadığım bir hücre olduğu için ne anlama geldiğini bilmek daha zordur. Aşağıdaki kod bunu daha net hale getirecektir.

Ofsetİşlem KoduYığın
85PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xff....ff Storage[3] 0x9D 0x00
9AANDStorage[3]-as-address 0x9D 0x00

Bu işlem kodları, Storage[3]'ten okuduğumuz değeri bir Ethereum adresinin uzunluğu olan 160 bite keser.

Ofsetİşlem KoduYığın
9BSWAP10x9D Storage[3]-as-address 0x00
9CJUMPStorage[3]-as-address 0x00

Bir sonraki işlem koduna geçeceğimiz için bu atlama gereksizdir. Bu kod olabileceği kadar gaz açısından verimli değildir.

Ofsetİşlem KoduYığın
9DJUMPDESTStorage[3]-as-address 0x00
9ESWAP10x00 Storage[3]-as-address
9FPOPStorage[3]-as-address
A0PUSH1 0x400x40 Storage[3]-as-address
A2MLOADMem[0x40] Storage[3]-as-address

Kodun en başında Mem[0x40] değerini 0x80 olarak ayarladık. Daha sonra 0x40'ı ararsak, onu değiştirmediğimizi görürüz - bu yüzden 0x80 olduğunu varsayabiliriz.

Ofsetİşlem KoduYığın
A3CALLDATASIZECALLDATASIZE 0x80 Storage[3]-as-address
A4PUSH1 0x000x00 CALLDATASIZE 0x80 Storage[3]-as-address
A6DUP30x80 0x00 CALLDATASIZE 0x80 Storage[3]-as-address
A7CALLDATACOPY0x80 Storage[3]-as-address

Tüm çağrı verisini 0x80'den başlayarak belleğe kopyalayın.

Ofsetİşlem KoduYığın
A8PUSH1 0x000x00 0x80 Storage[3]-as-address
AADUP10x00 0x00 0x80 Storage[3]-as-address
ABCALLDATASIZECALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address
ACDUP40x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address
ADDUP6Storage[3]-as-address 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address
AEGASGAS Storage[3]-as-address 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address
AFDELEGATE_CALL

Şimdi işler çok daha net. Bu sözleşme, asıl işi yapması için Storage[3]'teki adresi çağırarak bir vekil kontrat (opens in a new tab) olarak hareket edebilir. DELEGATE_CALL ayrı bir sözleşmeyi çağırır, ancak aynı depolamada kalır. Bu, yetkilendirilen sözleşmenin, yani vekili olduğumuz sözleşmenin aynı depolama alanına eriştiği anlamına gelir. Çağrı için parametreler şunlardır:

  • Gaz: Kalan tüm gaz
  • Çağrılan adres: Storage[3]-as-address
  • Çağrı verisi: Orijinal çağrı verisini koyduğumuz yer olan 0x80'den başlayan CALLDATASIZE baytları
  • Dönüş verisi: Yok (0x00 - 0x00) Dönüş verisini başka yollarla alacağız (aşağıya bakın)
Ofsetİşlem KoduYığın
B0RETURNDATASIZERETURNDATASIZE (((çağrı başarısı/başarısızlığı))) 0x80 Storage[3]-as-address
B1DUP1RETURNDATASIZE RETURNDATASIZE (((çağrı başarısı/başarısızlığı))) 0x80 Storage[3]-as-address
B2PUSH1 0x000x00 RETURNDATASIZE RETURNDATASIZE (((çağrı başarısı/başarısızlığı))) 0x80 Storage[3]-as-address
B4DUP50x80 0x00 RETURNDATASIZE RETURNDATASIZE (((çağrı başarısı/başarısızlığı))) 0x80 Storage[3]-as-address
B5RETURNDATACOPYRETURNDATASIZE (((çağrı başarısı/başarısızlığı))) 0x80 Storage[3]-as-address

Burada tüm dönüş verisini 0x80'den başlayan bellek arabelleğine kopyalıyoruz.

Ofsetİşlem KoduYığın
B6DUP2(((çağrı başarısı/başarısızlığı))) RETURNDATASIZE (((çağrı başarısı/başarısızlığı))) 0x80 Storage[3]-as-address
B7DUP1(((çağrı başarısı/başarısızlığı))) (((çağrı başarısı/başarısızlığı))) RETURNDATASIZE (((çağrı başarısı/başarısızlığı))) 0x80 Storage[3]-as-address
B8ISZERO(((çağrı başarısız oldu mu))) (((çağrı başarısı/başarısızlığı))) RETURNDATASIZE (((çağrı başarısı/başarısızlığı))) 0x80 Storage[3]-as-address
B9PUSH2 0x00c00xC0 (((çağrı başarısız oldu mu))) (((çağrı başarısı/başarısızlığı))) RETURNDATASIZE (((çağrı başarısı/başarısızlığı))) 0x80 Storage[3]-as-address
BCJUMPI(((çağrı başarısı/başarısızlığı))) RETURNDATASIZE (((çağrı başarısı/başarısızlığı))) 0x80 Storage[3]-as-address
BDDUP2RETURNDATASIZE (((çağrı başarısı/başarısızlığı))) RETURNDATASIZE (((çağrı başarısı/başarısızlığı))) 0x80 Storage[3]-as-address
BEDUP50x80 RETURNDATASIZE (((çağrı başarısı/başarısızlığı))) RETURNDATASIZE (((çağrı başarısı/başarısızlığı))) 0x80 Storage[3]-as-address
BFRETURN

Yani çağrıdan sonra dönüş verisini 0x80 - 0x80+RETURNDATASIZE arabelleğine kopyalıyoruz ve çağrı başarılı olursa tam olarak bu arabellek ile RETURN yapıyoruz.

DELEGATECALL Başarısız Oldu

Eğer buraya, 0xC0'a gelirsek, bu çağırdığımız sözleşmenin geri alındığı anlamına gelir. O sözleşme için sadece bir vekil kontrat olduğumuzdan, aynı veriyi döndürmek ve aynı zamanda geri almak istiyoruz.

Ofsetİşlem KoduYığın
C0JUMPDEST(((çağrı başarısı/başarısızlığı))) RETURNDATASIZE (((çağrı başarısı/başarısızlığı))) 0x80 Storage[3]-as-address
C1DUP2RETURNDATASIZE (((çağrı başarısı/başarısızlığı))) RETURNDATASIZE (((çağrı başarısı/başarısızlığı))) 0x80 Storage[3]-as-address
C2DUP50x80 RETURNDATASIZE (((çağrı başarısı/başarısızlığı))) RETURNDATASIZE (((çağrı başarısı/başarısızlığı))) 0x80 Storage[3]-as-address
C3REVERT

Böylece daha önce RETURN için kullandığımız aynı arabellek ile REVERT yapıyoruz: 0x80 - 0x80+RETURNDATASIZE

Call to proxy flowchart

ABI çağrıları

Eğer çağrı verisi boyutu dört bayt veya daha fazlaysa, bu geçerli bir ABI çağrısı olabilir.

Ofsetİşlem koduYığın
DPUSH1 0x000x00
FCALLDATALOAD(((Çağrı verisinin ilk kelimesi (256 bit))))
10PUSH1 0xe00xE0 (((Çağrı verisinin ilk kelimesi (256 bit))))
12SHR(((çağrı verisinin ilk 32 biti (4 bayt))))

Etherscan bize 1C'nin bilinmeyen bir işlem kodu olduğunu söylüyor, çünkü Etherscan bu özelliği yazdıktan sonra eklendi (opens in a new tab) ve henüz güncellemediler. Güncel bir işlem kodu tablosu (opens in a new tab) bize bunun sağa kaydırma olduğunu gösteriyor.

Ofsetİşlem koduYığın
13DUP1(((çağrı verisinin ilk 32 biti (4 bayt)))) (((çağrı verisinin ilk 32 biti (4 bayt))))
14PUSH4 0x3cd8045e0x3CD8045E (((çağrı verisinin ilk 32 biti (4 bayt)))) (((çağrı verisinin ilk 32 biti (4 bayt))))
19GT0x3CD8045E>cagri-verisinin-ilk-32-biti (((çağrı verisinin ilk 32 biti (4 bayt))))
1APUSH2 0x00430x43 0x3CD8045E>cagri-verisinin-ilk-32-biti (((çağrı verisinin ilk 32 biti (4 bayt))))
1DJUMPI(((çağrı verisinin ilk 32 biti (4 bayt))))

Yöntem imzası eşleştirme testlerini bu şekilde ikiye bölmek, ortalama olarak testlerin yarısından tasarruf sağlar. Bunu hemen takip eden kod ve 0x43'teki kod aynı kalıbı izler: çağrı verisinin ilk 32 bitini DUP1 ile kopyala, PUSH4 (((method signature> ekle, eşitliği kontrol etmek için EQ çalıştır ve ardından yöntem imzası eşleşirse JUMPI ile atla. İşte yöntem imzaları, adresleri ve biliniyorsa ilgili yöntem tanımı (opens in a new tab):

YöntemYöntem imzasıAtlanacak ofset
splitter() (opens in a new tab)0x3cd8045e0x0103
???0x81e580d30x0138
currentWindow() (opens in a new tab)0xba0bafb40x0158
???0x1f1358230x00C4
merkleRoot() (opens in a new tab)0x2eb4a7ab0x00ED

Eğer eşleşme bulunamazsa kod, vekili olduğumuz sözleşmede bir eşleşme olması umuduyla 0x7C'deki vekil işleyiciye atlar.

ABI calls flowchart

splitter()

Offsetİşlem koduYığın
103JUMPDEST
104CALLVALUECALLVALUE
105DUP1CALLVALUE CALLVALUE
106ISZEROCALLVALUE==0 CALLVALUE
107PUSH2 0x010f0x010F CALLVALUE==0 CALLVALUE
10AJUMPICALLVALUE
10BPUSH1 0x000x00 CALLVALUE
10DDUP10x00 0x00 CALLVALUE
10EREVERT

Bu fonksiyonun yaptığı ilk şey, çağrının herhangi bir ETH göndermediğini kontrol etmektir. Bu fonksiyon payable (opens in a new tab) değildir. Eğer birisi bize ETH gönderdiyse bu bir hata olmalıdır ve bu ETH'nin geri alamayacakları bir yerde kalmasını önlemek için REVERT yapmak istiyoruz.

Offsetİşlem koduYığın
10FJUMPDEST
110POP
111PUSH1 0x030x03
113SLOAD(((Storage[3] yani vekili olduğumuz sözleşme)))
114PUSH1 0x400x40 (((Storage[3] yani vekili olduğumuz sözleşme)))
116MLOAD0x80 (((Storage[3] yani vekili olduğumuz sözleşme)))
117PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xFF...FF 0x80 (((Storage[3] yani vekili olduğumuz sözleşme)))
12CSWAP10x80 0xFF...FF (((Storage[3] yani vekili olduğumuz sözleşme)))
12DSWAP2(((Storage[3] yani vekili olduğumuz sözleşme))) 0xFF...FF 0x80
12EANDProxyAddr 0x80
12FDUP20x80 ProxyAddr 0x80
130MSTORE0x80

Ve 0x80 artık vekil adresini içeriyor

Offsetİşlem koduYığın
131PUSH1 0x200x20 0x80
133ADD0xA0
134PUSH2 0x00e40xE4 0xA0
137JUMP0xA0

E4 Kodu

Bu satırları ilk kez görüyoruz, ancak diğer metotlarla paylaşılıyorlar (aşağıya bakın). Bu yüzden yığındaki değere X diyeceğiz ve splitter() içinde bu X değerinin 0xA0 olduğunu hatırlayacağız.

Offsetİşlem koduYığın
E4JUMPDESTX
E5PUSH1 0x400x40 X
E7MLOAD0x80 X
E8DUP10x80 0x80 X
E9SWAP2X 0x80 0x80
EASUBX-0x80 0x80
EBSWAP10x80 X-0x80
ECRETURN

Yani bu kod, yığında (X) bir bellek işaretçisi alır ve sözleşmenin 0x80 - X boyutunda bir arabellek ile RETURN yapmasına neden olur.

splitter() durumunda, bu, vekili olduğumuz adresi döndürür. RETURN, bu veriyi yazdığımız yer olan (yukarıdaki 0x130 konumu) 0x80-0x9F aralığındaki arabelleği döndürür.

currentWindow()

0x158-0x163 ofsetlerindeki kod, splitter() içinde 0x103-0x10E'de gördüğümüzle aynıdır (JUMPI hedefi dışında), bu yüzden currentWindow()'ün de payable olmadığını biliyoruz.

Ofsetİşlem koduYığın
164JUMPDEST
165POP
166PUSH2 0x00da0xDA
169PUSH1 0x010x01 0xDA
16BSLOADStorage[1] 0xDA
16CDUP20xDA Storage[1] 0xDA
16DJUMPStorage[1] 0xDA

DA kodu

Bu kod diğer metotlarla da paylaşılıyor. Bu yüzden yığındaki değere Y diyeceğiz ve currentWindow() içinde bu Y değerinin Storage[1] olduğunu hatırlayacağız.

Ofsetİşlem koduYığın
DAJUMPDESTY 0xDA
DBPUSH1 0x400x40 Y 0xDA
DDMLOAD0x80 Y 0xDA
DESWAP1Y 0x80 0xDA
DFDUP20x80 Y 0x80 0xDA
E0MSTORE0x80 0xDA

Y'yi 0x80-0x9F'ye yazın.

Ofsetİşlem koduYığın
E1PUSH1 0x200x20 0x80 0xDA
E3ADD0xA0 0xDA

Ve geri kalanı zaten yukarıda açıklandı. Yani 0xDA'ya atlamalar, yığının en üstündekini (Y) 0x80-0x9F'ye yazar ve bu değeri döndürür. currentWindow() durumunda, Storage[1] döndürür.

merkleRoot()

0xED-0xF8 ofsetlerindeki kod, (JUMPI hedefi dışında) splitter() içinde 0x103-0x10E'de gördüğümüzle aynıdır, bu yüzden merkleRoot()'nin de payable olmadığını biliyoruz.

Ofsetİşlem KoduYığın
F9JUMPDEST
FAPOP
FBPUSH2 0x00da0xDA
FEPUSH1 0x000x00 0xDA
100SLOADStorage[0] 0xDA
101DUP20xDA Storage[0] 0xDA
102JUMPStorage[0] 0xDA

Atlamadan sonra ne olacağını zaten çözmüştük. Yani merkleRoot(), Storage[0] döndürür.

0x81e580d3

0x138-0x143 ofsetlerindeki kod, splitter() içindeki 0x103-0x10E'de gördüğümüzle aynıdır (JUMPI hedefi dışında), bu yüzden bu fonksiyonun da payable olmadığını biliyoruz.

Ofsetİşlem koduYığın
144JUMPDEST
145POP
146PUSH2 0x00da0xDA
149PUSH2 0x01530x0153 0xDA
14CCALLDATASIZECALLDATASIZE 0x0153 0xDA
14DPUSH1 0x040x04 CALLDATASIZE 0x0153 0xDA
14FPUSH2 0x018f0x018F 0x04 CALLDATASIZE 0x0153 0xDA
152JUMP0x04 CALLDATASIZE 0x0153 0xDA
18FJUMPDEST0x04 CALLDATASIZE 0x0153 0xDA
190PUSH1 0x000x00 0x04 CALLDATASIZE 0x0153 0xDA
192PUSH1 0x200x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA
194DUP30x04 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA
195DUP5CALLDATASIZE 0x04 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA
196SUBCALLDATASIZE-4 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA
197SLTCALLDATASIZE-4<32 0x00 0x04 CALLDATASIZE 0x0153 0xDA
198ISZEROCALLDATASIZE-4>=32 0x00 0x04 CALLDATASIZE 0x0153 0xDA
199PUSH2 0x01a00x01A0 CALLDATASIZE-4>=32 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19CJUMPI0x00 0x04 CALLDATASIZE 0x0153 0xDA

Görünüşe göre bu fonksiyon en az 32 bayt (bir kelime) çağrı verisi alıyor.

Ofsetİşlem koduYığın
19DDUP10x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19EDUP20x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19FREVERT

Çağrı verisini almazsa, işlem herhangi bir dönüş verisi olmadan geri alınır.

Fonksiyon ihtiyaç duyduğu çağrı verisini alırsa ne olacağına bakalım.

Ofsetİşlem koduYığın
1A0JUMPDEST0x00 0x04 CALLDATASIZE 0x0153 0xDA
1A1POP0x04 CALLDATASIZE 0x0153 0xDA
1A2CALLDATALOADcalldataload(4) CALLDATASIZE 0x0153 0xDA

calldataload(4), metot imzasından sonraki çağrı verisinin ilk kelimesidir

Ofsetİşlem koduYığın
1A3SWAP20x0153 CALLDATASIZE calldataload(4) 0xDA
1A4SWAP1CALLDATASIZE 0x0153 calldataload(4) 0xDA
1A5POP0x0153 calldataload(4) 0xDA
1A6JUMPcalldataload(4) 0xDA
153JUMPDESTcalldataload(4) 0xDA
154PUSH2 0x016e0x016E calldataload(4) 0xDA
157JUMPcalldataload(4) 0xDA
16EJUMPDESTcalldataload(4) 0xDA
16FPUSH1 0x040x04 calldataload(4) 0xDA
171DUP2calldataload(4) 0x04 calldataload(4) 0xDA
172DUP20x04 calldataload(4) 0x04 calldataload(4) 0xDA
173SLOADStorage[4] calldataload(4) 0x04 calldataload(4) 0xDA
174DUP2calldataload(4) Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA
175LTcalldataload(4)<Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA
176PUSH2 0x017e0x017EC calldataload(4)<Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA
179JUMPIcalldataload(4) 0x04 calldataload(4) 0xDA

İlk kelime Storage[4]'ten küçük değilse, fonksiyon başarısız olur. Herhangi bir dönüş değeri olmadan geri alınır:

Ofsetİşlem koduYığın
17APUSH1 0x000x00 ...
17CDUP10x00 0x00 ...
17DREVERT

calldataload(4), Storage[4]'ten küçükse şu kodu elde ederiz:

Ofsetİşlem koduYığın
17EJUMPDESTcalldataload(4) 0x04 calldataload(4) 0xDA
17FPUSH1 0x000x00 calldataload(4) 0x04 calldataload(4) 0xDA
181SWAP20x04 calldataload(4) 0x00 calldataload(4) 0xDA
182DUP30x00 0x04 calldataload(4) 0x00 calldataload(4) 0xDA
183MSTOREcalldataload(4) 0x00 calldataload(4) 0xDA

Ve 0x00-0x1F bellek konumları artık 0x04 verisini içerir (0x00-0x1E tamamen sıfırdır, 0x1F ise dörttür)

Ofsetİşlem koduYığın
184PUSH1 0x200x20 calldataload(4) 0x00 calldataload(4) 0xDA
186SWAP1calldataload(4) 0x20 0x00 calldataload(4) 0xDA
187SWAP20x00 0x20 calldataload(4) calldataload(4) 0xDA
188SHA3(((SHA3 of 0x00-0x1F))) calldataload(4) calldataload(4) 0xDA
189ADD(((SHA3 of 0x00-0x1F)))+calldataload(4) calldataload(4) 0xDA
18ASLOADStorage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] calldataload(4) 0xDA

Yani depolamada, 0x000...0004'ün SHA3'ünden başlayan ve her geçerli çağrı verisi değeri (Storage[4] altındaki değer) için bir girdi içeren bir arama tablosu vardır.

Ofsetİşlem koduYığın
18BSWAP1calldataload(4) Storage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA
18CPOPStorage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA
18DDUP20xDA Storage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA
18EJUMPStorage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA

Biz zaten 0xDA ofsetindeki kodun ne yaptığını biliyoruz, yığının en üstündeki değeri çağırana döndürür. Yani bu fonksiyon, arama tablosundaki değeri çağırana döndürür.

0x1f135823

0xC4-0xCF ofsetlerindeki kod, splitter() içinde 0x103-0x10E'de gördüğümüzle aynıdır (JUMPI hedefi dışında), bu yüzden bu fonksiyonun da payable olmadığını biliyoruz.

Ofsetİşlem KoduYığın
D0JUMPDEST
D1POP
D2PUSH2 0x00da0xDA
D5PUSH1 0x060x06 0xDA
D7SLOADValue* 0xDA
D8DUP20xDA Value* 0xDA
D9JUMPValue* 0xDA

0xDA ofsetindeki kodun ne yaptığını zaten biliyoruz, yığının en üstündeki değeri çağırana döndürür. Yani bu fonksiyon Value* döndürür.

Metot Özeti

Bu noktada sözleşmeyi anladığınızı hissediyor musunuz? Ben hissetmiyorum. Şimdiye kadar şu metotlara sahibiz:

MetotAnlamı
TransferÇağrı tarafından sağlanan değeri kabul et ve Value* değerini bu miktar kadar artır
splitter()Storage[3]'ü, yani vekil kontrat adresini döndür
currentWindow()Storage[1]'i döndür
merkleRoot()Storage[0]'ı döndür
0x81e580d3Parametrenin Storage[4]'ten küçük olması koşuluyla, bir arama tablosundan değeri döndür
0x1f135823Storage[6]'yı, diğer adıyla Value*'ı döndür

Ancak diğer tüm işlevlerin Storage[3]'teki sözleşme tarafından sağlandığını biliyoruz. Belki o sözleşmenin ne olduğunu bilseydik bize bir ipucu verebilirdi. Neyse ki, bu bir blokzincir ve en azından teoride her şey biliniyor. Storage[3]'ü ayarlayan herhangi bir metot görmedik, bu yüzden kurucu tarafından ayarlanmış olmalı.

Kurucu

Bir sözleşmeye baktığımızda (opens in a new tab) onu oluşturan işlemi de görebiliriz.

Click the create transaction

Bu işleme ve ardından Durum sekmesine tıklarsak, parametrelerin başlangıç değerlerini görebiliriz. Özellikle, Storage[3]'ün 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761 (opens in a new tab) içerdiğini görebiliriz. Bu sözleşme eksik işlevselliği içeriyor olmalıdır. İncelediğimiz sözleşme için kullandığımız aynı araçları kullanarak onu anlayabiliriz.

Vekil Kontrat

Yukarıdaki orijinal sözleşme için kullandığımız aynı teknikleri kullanarak, sözleşmenin şu durumlarda geri alındığını görebiliriz:

  • Çağrıya eklenmiş herhangi bir ETH varsa (0x05-0x0F)
  • Çağrı verisi boyutu dörtten küçükse (0x10-0x19 ve 0xBE-0xC2)

Ve desteklediği yöntemler şunlardır:

Alttaki dört yöntemi görmezden gelebiliriz çünkü onlara asla ulaşamayacağız. İmzaları, orijinal sözleşmemizin bunları kendi başına halledeceği şekildedir (ayrıntıları yukarıda görmek için imzalara tıklayabilirsiniz), bu yüzden bunlar geçersiz kılınan yöntemler (opens in a new tab) olmalıdır.

Kalan yöntemlerden biri claim(<params>) ve diğeri isClaimed(<params>)'dir, bu yüzden bir airdrop sözleşmesi gibi görünüyor. Geri kalanını işlem kodu (opcode) işlem kodu incelemek yerine, bu sözleşmedeki üç işlev için kullanılabilir sonuçlar üreten geri derleyiciyi (decompiler) deneyebiliriz (opens in a new tab). Diğerlerini tersine mühendislikle çözmek okuyucuya bir alıştırma olarak bırakılmıştır.

scaleAmountByPercentage

Geri derleyicinin bu işlev için bize verdiği şey şudur:

def unknown8ffb5c97(uint256 _param1, uint256 _param2) payable:
  require calldata.size - 4 >=64
  if _param1 and _param2 > -1 / _param1:
      revert with 0, 17
  return (_param1 * _param2 / 100 * 10^6)

İlk require, çağrı verisinin, işlev imzasının dört baytına ek olarak, iki parametre için yeterli olan en az 64 bayta sahip olup olmadığını test eder. Eğer yoksa, açıkça yanlış bir şeyler vardır.

if ifadesi, _param1 değerinin sıfır olmadığını ve _param1 * _param2 değerinin negatif olmadığını kontrol ediyor gibi görünüyor. Bu muhtemelen başa sarma (wrap around) durumlarını önlemek içindir.

Son olarak, işlev ölçeklenmiş bir değer döndürür.

claim

Geri derleyicinin oluşturduğu kod karmaşıktır ve tamamı bizimle ilgili değildir. Yararlı bilgiler sağladığına inandığım satırlara odaklanmak için bir kısmını atlayacağım.

def unknown2e7ba6ef(uint256 _param1, uint256 _param2, uint256 _param3, array _param4) payable:
  ...
  require _param2 == addr(_param2)
  ...
  if currentWindow <= _param1:
      revert with 0, 'cannot claim for a future window'

Burada iki önemli şey görüyoruz:

  • _param2, bir uint256 olarak bildirilmiş olsa da, aslında bir adrestir
  • _param1, talep edilen penceredir ve currentWindow veya daha öncesi olmalıdır.
  ...
  if stor5[_claimWindow][addr(_claimFor)]:
      revert with 0, 'Account already claimed the given window'

Böylece artık Storage[5]'in pencereler ve adreslerden oluşan bir dizi olduğunu ve adresin o pencere için ödülü talep edip etmediğini biliyoruz.

unknown2eb4a7ab'nın aslında merkleRoot() işlevi olduğunu biliyoruz, bu nedenle bu kod bir Merkle kanıtını (opens in a new tab) doğruluyor gibi görünüyor. Bu, _param4'nin bir Merkle kanıtı olduğu anlamına gelir.

  call addr(_param2) with:
     value unknown81e580d3[_param1] * _param3 / 100 * 10^6 wei
       gas 30000 wei

Bir sözleşme kendi ETH'sini başka bir adrese (sözleşme veya harici olarak sahip olunan) bu şekilde transfer eder. Onu, transfer edilecek miktar olan bir değerle çağırır. Yani bu bir ETH airdrop'u gibi görünüyor.

  if not return_data.size:
      if not ext_call.success:
          require ext_code.size(stor2)
          call stor2.deposit() with:
             value unknown81e580d3[_param1] * _param3 / 100 * 10^6 wei

Alttaki iki satır bize Storage[2]'nin aynı zamanda çağırdığımız bir sözleşme olduğunu söylüyor. Kurucu (constructor) işlemine bakarsak (opens in a new tab), bu sözleşmenin 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 (opens in a new tab), yani kaynak kodu Etherscan'e yüklenmiş (opens in a new tab) bir Sarılmış Ether (Wrapped Ether) sözleşmesi olduğunu görürüz.

Yani sözleşmeler _param2 adresine ETH göndermeye çalışıyor gibi görünüyor. Bunu yapabilirse, harika. Yapamazsa, WETH (opens in a new tab) göndermeye çalışır. Eğer _param2 harici olarak sahip olunan bir hesap (EOA) ise her zaman ETH alabilir, ancak sözleşmeler ETH almayı reddedebilir. Ancak, WETH bir ERC-20'dir ve sözleşmeler bunu kabul etmeyi reddedemez.

  ...
  log 0xdbd5389f: addr(_param2), unknown81e580d3[_param1] * _param3 / 100 * 10^6, bool(ext_call.success)

İşlevin sonunda bir günlük girdisinin oluşturulduğunu görüyoruz. Oluşturulan günlük girdilerine bakın (opens in a new tab) ve 0xdbd5... ile başlayan konuyu filtreleyin. Böyle bir girdi oluşturan işlemlerden birine tıklarsak (opens in a new tab), bunun gerçekten de bir talep gibi göründüğünü anlarız - hesap, tersine mühendislik yaptığımız sözleşmeye bir mesaj gönderdi ve karşılığında ETH aldı.

A claim transaction

1e7df9d3

Bu işlev, yukarıdaki claim işlevine çok benzer. Ayrıca bir Merkle kanıtını kontrol eder, ilkine ETH transfer etmeye çalışır ve aynı türde günlük girdisi üretir.

Temel fark, ilk parametre olan çekilecek pencerenin orada olmamasıdır. Bunun yerine, talep edilebilecek tüm pencereler üzerinde bir döngü vardır.

Yani tüm pencereleri talep eden bir claim varyantı gibi görünüyor.

Sonuç

Şimdiye kadar, işlem kodlarını veya (çalıştığında) geri derleyiciyi kullanarak kaynak kodu mevcut olmayan sözleşmeleri nasıl anlayacağınızı öğrenmiş olmalısınız. Bu makalenin uzunluğundan da anlaşılacağı üzere, bir sözleşmeye tersine mühendislik uygulamak basit bir iş değildir, ancak güvenliğin esas olduğu bir sistemde sözleşmelerin vaat edildiği gibi çalıştığını doğrulayabilmek önemli bir beceridir.

Çalışmalarımın daha fazlasını görmek için buraya göz atın (opens in a new tab).