Chuyển đến nội dung chính

Dịch ngược một hợp đồng

evm
mã lệnh
Nâng cao
Ori Pomerantz
30 tháng 12, 2021
37 phút đọc

Giới thiệu

Không có bí mật nào trên Chuỗi khối, mọi thứ diễn ra đều nhất quán, có thể xác minh và công khai. Lý tưởng nhất là các hợp đồng nên có mã nguồn được công bố và xác minh trên Etherscan (opens in a new tab). Tuy nhiên, không phải lúc nào cũng như vậy (opens in a new tab). Trong bài viết này, bạn sẽ học cách dịch ngược các hợp đồng bằng cách xem xét một hợp đồng không có mã nguồn, 0x2510c039cc3b061d79e564b38836da87e31b342f (opens in a new tab).

Có các trình biên dịch ngược, nhưng chúng không phải lúc nào cũng tạo ra kết quả có thể sử dụng được (opens in a new tab). Trong bài viết này, bạn sẽ học cách dịch ngược thủ công và hiểu một hợp đồng từ các mã lệnh (opens in a new tab), cũng như cách diễn giải kết quả của một trình dịch ngược.

Để có thể hiểu bài viết này, bạn nên biết những kiến thức cơ bản về EVM và ít nhất là có chút quen thuộc với hợp ngữ EVM. Bạn có thể đọc về các chủ đề này tại đây (opens in a new tab).

Chuẩn bị mã thực thi

Bạn có thể lấy các mã lệnh bằng cách truy cập Etherscan cho hợp đồng, nhấp vào tab Contract và sau đó chọn Switch to Opcodes View. Bạn sẽ nhận được một chế độ xem với mỗi mã lệnh trên một dòng.

Opcode View from Etherscan

Tuy nhiên, để có thể hiểu được các lệnh nhảy, bạn cần biết vị trí của từng mã lệnh trong mã. Để làm điều đó, một cách là mở Google Spreadsheet và dán các mã lệnh vào cột C. Bạn có thể bỏ qua các bước sau bằng cách tạo một bản sao của bảng tính đã được chuẩn bị sẵn này (opens in a new tab).

Bước tiếp theo là lấy các vị trí mã chính xác để chúng ta có thể hiểu được các lệnh nhảy. Chúng ta sẽ đặt kích thước mã lệnh ở cột B và vị trí (ở hệ thập lục phân) ở cột A. Nhập hàm này vào ô B1 và sau đó sao chép và dán nó cho phần còn lại của cột B, cho đến cuối mã. Sau khi làm điều này, bạn có thể ẩn cột B.

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

Đầu tiên, hàm này thêm một byte cho chính mã lệnh đó, và sau đó tìm kiếm PUSH. Các mã lệnh PUSH rất đặc biệt vì chúng cần có thêm các byte cho giá trị đang được đẩy vào. Nếu mã lệnh là PUSH, chúng ta trích xuất số lượng byte và cộng thêm vào.

Trong A1, hãy đặt độ lệch đầu tiên là 0. Sau đó, trong A2, hãy đặt hàm này và lại sao chép và dán nó cho phần còn lại của cột A:

=dec2hex(hex2dec(A1)+B1)

Chúng ta cần hàm này cung cấp cho chúng ta giá trị hệ thập lục phân vì các giá trị được đẩy vào trước các lệnh nhảy (JUMPJUMPI) được cung cấp cho chúng ta ở hệ thập lục phân.

Điểm vào (0x00)

Các hợp đồng luôn được thực thi từ byte đầu tiên. Đây là phần đầu của mã:

OffsetMã lệnhNgăn xếp (sau mã lệnh)
0PUSH1 0x800x80
2PUSH1 0x400x40, 0x80
4MSTORETrống
5PUSH1 0x040x04
7CALLDATASIZECALLDATASIZE 0x04
8LTCALLDATASIZE<4
9PUSH2 0x005e0x5E CALLDATASIZE<4
CJUMPITrống

Đoạn mã này thực hiện hai việc:

  1. Ghi 0x80 dưới dạng giá trị 32 byte vào các vị trí bộ nhớ 0x40-0x5F (0x80 được lưu trữ ở 0x5F và 0x40-0x5E đều là các số không).
  2. Đọc kích thước dữ liệu lệnh gọi. Thông thường, dữ liệu lệnh gọi cho một hợp đồng Ethereum tuân theo ABI (giao diện nhị phân ứng dụng) (opens in a new tab), yêu cầu tối thiểu bốn byte cho bộ chọn hàm. Nếu kích thước dữ liệu lệnh gọi nhỏ hơn bốn, hãy nhảy đến 0x5E.

Flowchart for this portion

Trình xử lý tại 0x5E (dành cho dữ liệu lệnh gọi không thuộc ABI)

OffsetMã lệnh
5EJUMPDEST
5FCALLDATASIZE
60PUSH2 0x007c
63JUMPI

Đoạn mã này bắt đầu bằng một JUMPDEST. Các chương trình EVM (Máy ảo Ethereum) sẽ ném ra một ngoại lệ nếu bạn nhảy đến một mã lệnh không phải là JUMPDEST. Sau đó, nó xem xét CALLDATASIZE và nếu là "true" (tức là không phải số không) thì sẽ nhảy đến 0x7C. Chúng ta sẽ tìm hiểu điều đó ở bên dưới.

OffsetMã lệnhNgăn xếp (sau mã lệnh)
64CALLVALUE được cung cấp bởi lệnh gọi. Được gọi là msg.value trong Solidity
65PUSH1 0x066 CALLVALUE
67PUSH1 0x000 6 CALLVALUE
69DUP3CALLVALUE 0 6 CALLVALUE
6ADUP36 CALLVALUE 0 6 CALLVALUE
6BSLOADStorage[6] CALLVALUE 0 6 CALLVALUE

Vì vậy, khi không có dữ liệu lệnh gọi, chúng ta đọc giá trị của Storage[6]. Chúng ta chưa biết giá trị này là gì, nhưng chúng ta có thể tìm kiếm các giao dịch mà hợp đồng đã nhận mà không có dữ liệu lệnh gọi. Các giao dịch chỉ chuyển ETH mà không có bất kỳ dữ liệu lệnh gọi nào (và do đó không có phương thức) sẽ có phương thức Transfer trên Etherscan. Trên thực tế, giao dịch đầu tiên mà hợp đồng nhận được (opens in a new tab) là một khoản chuyển.

Nếu chúng ta xem xét giao dịch đó và nhấp vào Click to see More (Nhấp để xem thêm), chúng ta thấy rằng dữ liệu lệnh gọi, được gọi là dữ liệu đầu vào, thực sự trống (0x). Cũng lưu ý rằng giá trị là 1.559 ETH, điều này sẽ liên quan ở phần sau.

The call data is empty

Tiếp theo, nhấp vào tab State (Trạng thái) và mở rộng hợp đồng mà chúng ta đang dịch ngược (0x2510...). Bạn có thể thấy rằng Storage[6] đã thay đổi trong quá trình giao dịch và nếu bạn đổi Hex thành Number (Số), bạn sẽ thấy nó trở thành 1,559,000,000,000,000,000, giá trị được chuyển tính bằng wei (tôi đã thêm dấu phẩy cho rõ ràng), tương ứng với giá trị hợp đồng tiếp theo.

Sự thay đổi trong Storage[6]

Nếu chúng ta xem xét các thay đổi trạng thái do các giao dịch Transfer khác từ cùng thời kỳ (opens in a new tab) gây ra, chúng ta thấy rằng Storage[6] đã theo dõi giá trị của hợp đồng trong một thời gian. Hiện tại, chúng ta sẽ gọi nó là Value*. Dấu hoa thị (*) nhắc nhở chúng ta rằng chúng ta chưa biết biến này làm gì, nhưng nó không thể chỉ để theo dõi giá trị hợp đồng vì không cần thiết phải sử dụng bộ nhớ lưu trữ (storage), vốn rất đắt đỏ, khi bạn có thể lấy số dư tài khoản của mình bằng cách sử dụng ADDRESS BALANCE. Mã lệnh đầu tiên đẩy địa chỉ của chính hợp đồng. Mã lệnh thứ hai đọc địa chỉ ở trên cùng của ngăn xếp và thay thế nó bằng số dư của địa chỉ đó.

OffsetMã lệnhNgăn xếp
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

Chúng ta sẽ tiếp tục theo dõi đoạn mã này tại đích nhảy.

OffsetMã lệnhNgăn xếp
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 là phép toán bitwise (thao tác bit), vì vậy nó đảo ngược giá trị của mọi bit trong giá trị lệnh gọi.

OffsetMã lệnhNgăn xếp
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

Chúng ta nhảy nếu Value* nhỏ hơn 2^256-CALLVALUE-1 hoặc bằng nó. Điều này có vẻ giống như logic để ngăn chặn tràn số (overflow). Và thực sự, chúng ta thấy rằng sau một vài thao tác vô nghĩa (ví dụ như ghi vào bộ nhớ sắp bị xóa) tại offset 0x01DE, hợp đồng sẽ hoàn nguyên nếu phát hiện tràn số, đây là hành vi bình thường.

Lưu ý rằng việc tràn số như vậy là cực kỳ khó xảy ra, vì nó sẽ yêu cầu giá trị lệnh gọi cộng với Value* phải tương đương với 2^256 wei, khoảng 10^59 ETH. Tổng nguồn cung ETH, tại thời điểm viết bài, là chưa đến hai trăm triệu (opens in a new tab).

OffsetMã lệnhNgăn xếp
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

Nếu chúng ta đến được đây, hãy lấy Value* + CALLVALUE và nhảy đến offset 0x75.

OffsetMã lệnhNgăn xếp
75JUMPDESTValue*+CALLVALUE 0 6 CALLVALUE
76SWAP10 Value*+CALLVALUE 6 CALLVALUE
77SWAP26 Value*+CALLVALUE 0 CALLVALUE
78SSTORE0 CALLVALUE

Nếu chúng ta đến được đây (điều này yêu cầu dữ liệu lệnh gọi phải trống), chúng ta sẽ cộng thêm giá trị lệnh gọi vào Value*. Điều này nhất quán với những gì chúng ta nói về các giao dịch Transfer.

OffsetMã lệnh
79POP
7APOP
7BSTOP

Cuối cùng, xóa ngăn xếp (điều này không cần thiết) và báo hiệu kết thúc giao dịch thành công.

Tóm lại, đây là lưu đồ cho đoạn mã ban đầu.

Entry point flowchart

Trình xử lý tại 0x7C

Tôi cố tình không đưa vào tiêu đề những gì trình xử lý này làm. Mục đích không phải là để dạy bạn cách hợp đồng cụ thể này hoạt động, mà là cách dịch ngược các hợp đồng. Bạn sẽ tìm hiểu những gì nó làm theo cách giống như tôi đã làm, bằng cách theo dõi mã.

Chúng ta đến đây từ một vài nơi:

  • Nếu có dữ liệu lệnh gọi gồm 1, 2 hoặc 3 byte (từ offset 0x63)
  • Nếu chữ ký phương thức không xác định (từ các offset 0x42 và 0x5D)
OffsetMã lệnhNgăn xếp
7CJUMPDEST
7DPUSH1 0x000x00
7FPUSH2 0x009d0x9D 0x00
82PUSH1 0x030x03 0x9D 0x00
84SLOADStorage[3] 0x9D 0x00

Đây là một ô lưu trữ khác, một ô mà tôi không thể tìm thấy trong bất kỳ giao dịch nào nên khó biết nó có nghĩa là gì hơn. Mã bên dưới sẽ làm cho nó rõ ràng hơn.

OffsetMã lệnhNgăn xếp
85PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xff....ff Storage[3] 0x9D 0x00
9AANDStorage[3]-dưới-dạng-địa-chỉ 0x9D 0x00

Các mã lệnh này cắt bớt giá trị chúng ta đọc từ Storage[3] xuống còn 160 bit, độ dài của một Địa chỉ Ethereum.

OffsetMã lệnhNgăn xếp
9BSWAP10x9D Storage[3]-dưới-dạng-địa-chỉ 0x00
9CJUMPStorage[3]-dưới-dạng-địa-chỉ 0x00

Bước nhảy này là thừa, vì chúng ta sẽ chuyển sang mã lệnh tiếp theo. Mã này không tiết kiệm gas như nó có thể.

OffsetMã lệnhNgăn xếp
9DJUMPDESTStorage[3]-dưới-dạng-địa-chỉ 0x00
9ESWAP10x00 Storage[3]-dưới-dạng-địa-chỉ
9FPOPStorage[3]-dưới-dạng-địa-chỉ
A0PUSH1 0x400x40 Storage[3]-dưới-dạng-địa-chỉ
A2MLOADMem[0x40] Storage[3]-dưới-dạng-địa-chỉ

Ngay từ đầu mã, chúng ta đã đặt Mem[0x40] thành 0x80. Nếu chúng ta tìm kiếm 0x40 sau đó, chúng ta thấy rằng chúng ta không thay đổi nó - vì vậy chúng ta có thể giả định nó là 0x80.

OffsetMã lệnhNgăn xếp
A3CALLDATASIZECALLDATASIZE 0x80 Storage[3]-dưới-dạng-địa-chỉ
A4PUSH1 0x000x00 CALLDATASIZE 0x80 Storage[3]-dưới-dạng-địa-chỉ
A6DUP30x80 0x00 CALLDATASIZE 0x80 Storage[3]-dưới-dạng-địa-chỉ
A7CALLDATACOPY0x80 Storage[3]-dưới-dạng-địa-chỉ

Sao chép toàn bộ dữ liệu lệnh gọi vào bộ nhớ, bắt đầu từ 0x80.

OffsetMã lệnhNgăn xếp
A8PUSH1 0x000x00 0x80 Storage[3]-dưới-dạng-địa-chỉ
AADUP10x00 0x00 0x80 Storage[3]-dưới-dạng-địa-chỉ
ABCALLDATASIZECALLDATASIZE 0x00 0x00 0x80 Storage[3]-dưới-dạng-địa-chỉ
ACDUP40x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-dưới-dạng-địa-chỉ
ADDUP6Storage[3]-dưới-dạng-địa-chỉ 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-dưới-dạng-địa-chỉ
AEGASGAS Storage[3]-dưới-dạng-địa-chỉ 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-dưới-dạng-địa-chỉ
AFDELEGATE_CALL

Bây giờ mọi thứ đã rõ ràng hơn nhiều. Hợp đồng này có thể hoạt động như một hợp đồng proxy (opens in a new tab), gọi Địa chỉ trong Storage[3] để thực hiện công việc thực sự. DELEGATE_CALL gọi một hợp đồng riêng biệt, nhưng vẫn ở trong cùng một không gian lưu trữ. Điều này có nghĩa là hợp đồng được ủy quyền, hợp đồng mà chúng ta làm proxy, truy cập vào cùng một không gian lưu trữ. Các tham số cho lệnh gọi là:

  • Gas: Tất cả lượng gas còn lại
  • Địa chỉ được gọi: Storage[3]-dưới-dạng-địa-chỉ
  • Dữ liệu lệnh gọi: Các byte CALLDATASIZE bắt đầu tại 0x80, đây là nơi chúng ta đặt dữ liệu lệnh gọi ban đầu
  • Dữ liệu trả về: Không có (0x00 - 0x00) Chúng ta sẽ lấy dữ liệu trả về bằng các phương tiện khác (xem bên dưới)
OffsetMã lệnhNgăn xếp
B0RETURNDATASIZERETURNDATASIZE (((gọi thành công/thất bại))) 0x80 Storage[3]-dưới-dạng-địa-chỉ
B1DUP1RETURNDATASIZE RETURNDATASIZE (((gọi thành công/thất bại))) 0x80 Storage[3]-dưới-dạng-địa-chỉ
B2PUSH1 0x000x00 RETURNDATASIZE RETURNDATASIZE (((gọi thành công/thất bại))) 0x80 Storage[3]-dưới-dạng-địa-chỉ
B4DUP50x80 0x00 RETURNDATASIZE RETURNDATASIZE (((gọi thành công/thất bại))) 0x80 Storage[3]-dưới-dạng-địa-chỉ
B5RETURNDATACOPYRETURNDATASIZE (((gọi thành công/thất bại))) 0x80 Storage[3]-dưới-dạng-địa-chỉ

Ở đây chúng ta sao chép toàn bộ dữ liệu trả về vào bộ đệm bộ nhớ bắt đầu tại 0x80.

OffsetMã lệnhNgăn xếp
B6DUP2(((gọi thành công/thất bại))) RETURNDATASIZE (((gọi thành công/thất bại))) 0x80 Storage[3]-dưới-dạng-địa-chỉ
B7DUP1(((gọi thành công/thất bại))) (((gọi thành công/thất bại))) RETURNDATASIZE (((gọi thành công/thất bại))) 0x80 Storage[3]-dưới-dạng-địa-chỉ
B8ISZERO(((lệnh gọi có thất bại không))) (((gọi thành công/thất bại))) RETURNDATASIZE (((gọi thành công/thất bại))) 0x80 Storage[3]-dưới-dạng-địa-chỉ
B9PUSH2 0x00c00xC0 (((lệnh gọi có thất bại không))) (((gọi thành công/thất bại))) RETURNDATASIZE (((gọi thành công/thất bại))) 0x80 Storage[3]-dưới-dạng-địa-chỉ
BCJUMPI(((gọi thành công/thất bại))) RETURNDATASIZE (((gọi thành công/thất bại))) 0x80 Storage[3]-dưới-dạng-địa-chỉ
BDDUP2RETURNDATASIZE (((gọi thành công/thất bại))) RETURNDATASIZE (((gọi thành công/thất bại))) 0x80 Storage[3]-dưới-dạng-địa-chỉ
BEDUP50x80 RETURNDATASIZE (((gọi thành công/thất bại))) RETURNDATASIZE (((gọi thành công/thất bại))) 0x80 Storage[3]-dưới-dạng-địa-chỉ
BFRETURN

Vì vậy, sau lệnh gọi, chúng ta sao chép dữ liệu trả về vào bộ đệm 0x80 - 0x80+RETURNDATASIZE, và nếu lệnh gọi thành công, sau đó chúng ta RETURN với chính xác bộ đệm đó.

DELEGATECALL Thất bại

Nếu chúng ta đến đây, tại 0xC0, điều đó có nghĩa là hợp đồng mà chúng ta đã gọi đã hoàn nguyên. Vì chúng ta chỉ là một hợp đồng proxy cho hợp đồng đó, chúng ta muốn trả về cùng một dữ liệu và cũng hoàn nguyên.

OffsetMã lệnhNgăn xếp
C0JUMPDEST(((gọi thành công/thất bại))) RETURNDATASIZE (((gọi thành công/thất bại))) 0x80 Storage[3]-dưới-dạng-địa-chỉ
C1DUP2RETURNDATASIZE (((gọi thành công/thất bại))) RETURNDATASIZE (((gọi thành công/thất bại))) 0x80 Storage[3]-dưới-dạng-địa-chỉ
C2DUP50x80 RETURNDATASIZE (((gọi thành công/thất bại))) RETURNDATASIZE (((gọi thành công/thất bại))) 0x80 Storage[3]-dưới-dạng-địa-chỉ
C3REVERT

Vì vậy, chúng ta REVERT với cùng một bộ đệm mà chúng ta đã sử dụng cho RETURN trước đó: 0x80 - 0x80+RETURNDATASIZE

Call to proxy flowchart

Các lệnh gọi ABI

Nếu kích thước dữ liệu lệnh gọi là bốn byte trở lên, đây có thể là một lệnh gọi ABI hợp lệ.

OffsetMã lệnhNgăn xếp
DPUSH1 0x000x00
FCALLDATALOAD(((Từ đầu tiên (256 bit) của dữ liệu lệnh gọi)))
10PUSH1 0xe00xE0 (((Từ đầu tiên (256 bit) của dữ liệu lệnh gọi)))
12SHR(((32 bit (4 byte) đầu tiên của dữ liệu lệnh gọi)))

Etherscan cho chúng ta biết rằng 1C là một mã lệnh không xác định, bởi vì nó đã được thêm vào sau khi Etherscan viết tính năng này (opens in a new tab) và họ chưa cập nhật nó. Một bảng mã lệnh cập nhật (opens in a new tab) cho chúng ta thấy rằng đây là phép dịch phải

OffsetMã lệnhNgăn xếp
13DUP1(((32 bit (4 byte) đầu tiên của dữ liệu lệnh gọi))) (((32 bit (4 byte) đầu tiên của dữ liệu lệnh gọi)))
14PUSH4 0x3cd8045e0x3CD8045E (((32 bit (4 byte) đầu tiên của dữ liệu lệnh gọi))) (((32 bit (4 byte) đầu tiên của dữ liệu lệnh gọi)))
19GT0x3CD8045E>32-bit-đầu-tiên-của-dữ-liệu-lệnh-gọi (((32 bit (4 byte) đầu tiên của dữ liệu lệnh gọi)))
1APUSH2 0x00430x43 0x3CD8045E>32-bit-đầu-tiên-của-dữ-liệu-lệnh-gọi (((32 bit (4 byte) đầu tiên của dữ liệu lệnh gọi)))
1DJUMPI(((32 bit (4 byte) đầu tiên của dữ liệu lệnh gọi)))

Bằng cách chia các bài kiểm tra khớp chữ ký phương thức làm hai như thế này sẽ tiết kiệm trung bình một nửa số bài kiểm tra. Mã ngay sau phần này và mã ở 0x43 tuân theo cùng một mẫu: DUP1 32 bit đầu tiên của dữ liệu lệnh gọi, PUSH4 (((method signature>, chạy EQ để kiểm tra tính bằng nhau, và sau đó JUMPI nếu chữ ký phương thức khớp. Dưới đây là các chữ ký phương thức, địa chỉ của chúng và nếu biết định nghĩa phương thức tương ứng (opens in a new tab):

Phương thứcChữ ký phương thứcOffset để nhảy tới
splitter() (opens in a new tab)0x3cd8045e0x0103
???0x81e580d30x0138
currentWindow() (opens in a new tab)0xba0bafb40x0158
???0x1f1358230x00C4
merkleRoot() (opens in a new tab)0x2eb4a7ab0x00ED

Nếu không tìm thấy kết quả khớp nào, mã sẽ nhảy tới trình xử lý proxy tại 0x7C, với hy vọng rằng hợp đồng mà chúng ta đang làm proxy có kết quả khớp.

ABI calls flowchart

splitter()

OffsetMã lệnhNgăn xếp
103JUMPDEST
104CALLVALUECALLVALUE
105DUP1CALLVALUE CALLVALUE
106ISZEROCALLVALUE==0 CALLVALUE
107PUSH2 0x010f0x010F CALLVALUE==0 CALLVALUE
10AJUMPICALLVALUE
10BPUSH1 0x000x00 CALLVALUE
10DDUP10x00 0x00 CALLVALUE
10EREVERT

Điều đầu tiên hàm này làm là kiểm tra xem lệnh gọi có gửi bất kỳ ETH nào hay không. Hàm này không phải là payable (opens in a new tab). Nếu ai đó đã gửi ETH cho chúng ta, đó chắc chắn là một sai sót và chúng ta muốn REVERT để tránh việc giữ số ETH đó ở nơi mà họ không thể lấy lại.

OffsetMã lệnhNgăn xếp
10FJUMPDEST
110POP
111PUSH1 0x030x03
113SLOAD(((Storage[3] hay còn gọi là hợp đồng mà chúng ta đang làm proxy)))
114PUSH1 0x400x40 (((Storage[3] hay còn gọi là hợp đồng mà chúng ta đang làm proxy)))
116MLOAD0x80 (((Storage[3] hay còn gọi là hợp đồng mà chúng ta đang làm proxy)))
117PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xFF...FF 0x80 (((Storage[3] hay còn gọi là hợp đồng mà chúng ta đang làm proxy)))
12CSWAP10x80 0xFF...FF (((Storage[3] hay còn gọi là hợp đồng mà chúng ta đang làm proxy)))
12DSWAP2(((Storage[3] hay còn gọi là hợp đồng mà chúng ta đang làm proxy))) 0xFF...FF 0x80
12EANDProxyAddr 0x80
12FDUP20x80 ProxyAddr 0x80
130MSTORE0x80

Và 0x80 bây giờ chứa địa chỉ proxy

OffsetMã lệnhNgăn xếp
131PUSH1 0x200x20 0x80
133ADD0xA0
134PUSH2 0x00e40xE4 0xA0
137JUMP0xA0

Mã E4

Đây là lần đầu tiên chúng ta thấy những dòng này, nhưng chúng được chia sẻ với các phương thức khác (xem bên dưới). Vì vậy, chúng ta sẽ gọi giá trị trong ngăn xếp là X, và chỉ cần nhớ rằng trong splitter(), giá trị của X này là 0xA0.

OffsetMã lệnhNgăn xếp
E4JUMPDESTX
E5PUSH1 0x400x40 X
E7MLOAD0x80 X
E8DUP10x80 0x80 X
E9SWAP2X 0x80 0x80
EASUBX-0x80 0x80
EBSWAP10x80 X-0x80
ECRETURN

Vì vậy, mã này nhận một con trỏ bộ nhớ trong ngăn xếp (X) và khiến hợp đồng RETURN với một bộ đệm là 0x80 - X.

Trong trường hợp của splitter(), điều này trả về địa chỉ mà chúng ta đang làm proxy. RETURN trả về bộ đệm trong khoảng 0x80-0x9F, đây là nơi chúng ta đã ghi dữ liệu này (offset 0x130 ở trên).

currentWindow()

Mã trong các offset 0x158-0x163 giống hệt với những gì chúng ta đã thấy ở 0x103-0x10E trong splitter() (ngoại trừ đích đến JUMPI), vì vậy chúng ta biết currentWindow() cũng không phải là payable.

OffsetMã lệnhNgăn xếp
164JUMPDEST
165POP
166PUSH2 0x00da0xDA
169PUSH1 0x010x01 0xDA
16BSLOADStorage[1] 0xDA
16CDUP20xDA Storage[1] 0xDA
16DJUMPStorage[1] 0xDA

Mã DA

Mã này cũng được chia sẻ với các phương thức khác. Vì vậy, chúng ta sẽ gọi giá trị trong ngăn xếp là Y, và chỉ cần nhớ rằng trong currentWindow() giá trị của Y này là Storage[1].

OffsetMã lệnhNgăn xếp
DAJUMPDESTY 0xDA
DBPUSH1 0x400x40 Y 0xDA
DDMLOAD0x80 Y 0xDA
DESWAP1Y 0x80 0xDA
DFDUP20x80 Y 0x80 0xDA
E0MSTORE0x80 0xDA

Ghi Y vào 0x80-0x9F.

OffsetMã lệnhNgăn xếp
E1PUSH1 0x200x20 0x80 0xDA
E3ADD0xA0 0xDA

Và phần còn lại đã được giải thích ở trên. Vì vậy, các bước nhảy đến 0xDA sẽ ghi đỉnh ngăn xếp (Y) vào 0x80-0x9F, và trả về giá trị đó. Trong trường hợp của currentWindow(), nó trả về Storage[1].

merkleRoot()

Mã ở các offset 0xED-0xF8 giống hệt với những gì chúng ta đã thấy ở 0x103-0x10E trong splitter() (ngoại trừ đích đến JUMPI), vì vậy chúng ta biết merkleRoot() cũng không phải là payable.

OffsetMã lệnhNgăn xếp
F9JUMPDEST
FAPOP
FBPUSH2 0x00da0xDA
FEPUSH1 0x000x00 0xDA
100SLOADStorage[0] 0xDA
101DUP20xDA Storage[0] 0xDA
102JUMPStorage[0] 0xDA

Những gì xảy ra sau lệnh nhảy thì chúng ta đã tìm ra. Vì vậy, merkleRoot() trả về Storage[0].

0x81e580d3

Đoạn mã ở các offset 0x138-0x143 giống hệt với những gì chúng ta đã thấy ở 0x103-0x10E trong splitter() (ngoại trừ đích đến JUMPI), vì vậy chúng ta biết hàm này cũng không phải là payable.

OffsetMã lệnhStack
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

Có vẻ như hàm này nhận ít nhất 32 byte (một word) dữ liệu lệnh gọi.

OffsetMã lệnhStack
19DDUP10x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19EDUP20x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19FREVERT

Nếu nó không nhận được dữ liệu lệnh gọi, giao dịch sẽ bị hoàn nguyên mà không có bất kỳ dữ liệu trả về nào.

Hãy xem điều gì sẽ xảy ra nếu hàm thực sự nhận được dữ liệu lệnh gọi mà nó cần.

OffsetMã lệnhStack
1A0JUMPDEST0x00 0x04 CALLDATASIZE 0x0153 0xDA
1A1POP0x04 CALLDATASIZE 0x0153 0xDA
1A2CALLDATALOADcalldataload(4) CALLDATASIZE 0x0153 0xDA

calldataload(4) là word đầu tiên của dữ liệu lệnh gọi sau chữ ký phương thức

OffsetMã lệnhStack
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

Nếu word đầu tiên không nhỏ hơn Storage[4], hàm sẽ thất bại. Nó hoàn nguyên mà không có bất kỳ giá trị trả về nào:

OffsetMã lệnhStack
17APUSH1 0x000x00 ...
17CDUP10x00 0x00 ...
17DREVERT

Nếu calldataload(4) nhỏ hơn Storage[4], chúng ta có đoạn mã này:

OffsetMã lệnhStack
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

Và các vị trí bộ nhớ 0x00-0x1F bây giờ chứa dữ liệu 0x04 (0x00-0x1E đều là các số không, 0x1F là bốn)

OffsetMã lệnhStack
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

Vì vậy, có một bảng tra cứu trong storage, bắt đầu tại SHA3 của 0x000...0004 và có một mục nhập cho mọi giá trị dữ liệu lệnh gọi hợp lệ (giá trị dưới Storage[4]).

OffsetMã lệnhStack
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

Chúng ta đã biết đoạn mã tại offset 0xDA làm gì, nó trả về giá trị trên cùng của stack cho người gọi. Vì vậy, hàm này trả về giá trị từ bảng tra cứu cho người gọi.

0x1f135823

Mã ở các offset 0xC4-0xCF giống hệt với những gì chúng ta đã thấy ở 0x103-0x10E trong splitter() (ngoại trừ đích đến JUMPI), vì vậy chúng ta biết hàm này cũng không phải là payable.

OffsetMã lệnhStack
D0JUMPDEST
D1POP
D2PUSH2 0x00da0xDA
D5PUSH1 0x060x06 0xDA
D7SLOADValue* 0xDA
D8DUP20xDA Value* 0xDA
D9JUMPValue* 0xDA

Chúng ta đã biết mã tại offset 0xDA làm gì, nó trả về giá trị trên cùng của stack cho người gọi. Vì vậy, hàm này trả về Value*.

Tóm tắt phương thức

Bạn có cảm thấy mình hiểu hợp đồng tại thời điểm này không? Tôi thì không. Cho đến nay chúng ta có các phương thức sau:

Phương thứcÝ nghĩa
TransferChấp nhận giá trị được cung cấp bởi lệnh gọi và tăng Value* lên một lượng tương ứng
splitter()Trả về Storage[3], địa chỉ proxy
currentWindow()Trả về Storage[1]
merkleRoot()Trả về Storage[0]
0x81e580d3Trả về giá trị từ một bảng tra cứu, với điều kiện tham số nhỏ hơn Storage[4]
0x1f135823Trả về Storage[6], hay còn gọi là Value*

Nhưng chúng ta biết bất kỳ chức năng nào khác đều được cung cấp bởi hợp đồng trong Storage[3]. Có lẽ nếu chúng ta biết hợp đồng đó là gì, nó sẽ cho chúng ta một manh mối. Rất may, đây là Chuỗi khối và mọi thứ đều được biết đến, ít nhất là về mặt lý thuyết. Chúng ta không thấy bất kỳ phương thức nào thiết lập Storage[3], vì vậy nó chắc chắn đã được thiết lập bởi hàm khởi tạo.

Hàm khởi tạo

Khi chúng ta xem xét một hợp đồng (opens in a new tab), chúng ta cũng có thể thấy giao dịch đã tạo ra nó.

Click the create transaction

Nếu chúng ta nhấp vào giao dịch đó, và sau đó là tab Trạng thái, chúng ta có thể thấy các giá trị ban đầu của các tham số. Cụ thể, chúng ta có thể thấy rằng Storage[3] chứa 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761 (opens in a new tab). Hợp đồng đó chắc chắn chứa chức năng còn thiếu. Chúng ta có thể hiểu nó bằng cách sử dụng cùng các công cụ mà chúng ta đã sử dụng cho hợp đồng đang được điều tra.

Hợp đồng proxy

Sử dụng cùng các kỹ thuật mà chúng ta đã dùng cho hợp đồng gốc ở trên, chúng ta có thể thấy rằng hợp đồng hoàn nguyên nếu:

  • Có bất kỳ ETH nào được đính kèm vào lệnh gọi (0x05-0x0F)
  • Kích thước dữ liệu lệnh gọi nhỏ hơn bốn (0x10-0x19 và 0xBE-0xC2)

Và các phương thức mà nó hỗ trợ là:

Chúng ta có thể bỏ qua bốn phương thức dưới cùng vì chúng ta sẽ không bao giờ dùng đến chúng. Chữ ký của chúng cho thấy hợp đồng gốc của chúng ta tự xử lý chúng (bạn có thể nhấp vào các chữ ký để xem chi tiết ở trên), vì vậy chúng chắc chắn là các phương thức bị ghi đè (opens in a new tab).

Một trong những phương thức còn lại là claim(<params>), và một phương thức khác là isClaimed(<params>), vì vậy nó trông giống như một hợp đồng airdrop. Thay vì đi qua phần còn lại theo từng mã lệnh, chúng ta có thể thử dùng trình dịch ngược (decompiler) (opens in a new tab), công cụ này tạo ra các kết quả có thể sử dụng được cho ba hàm từ hợp đồng này. Việc dịch ngược các hàm khác được để lại như một bài tập cho người đọc.

scaleAmountByPercentage

Đây là những gì trình dịch ngược cung cấp cho chúng ta đối với hàm này:

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)

Lệnh require đầu tiên kiểm tra xem dữ liệu lệnh gọi có, ngoài bốn byte của chữ ký hàm, ít nhất 64 byte hay không, đủ cho hai tham số. Nếu không thì rõ ràng là có điều gì đó sai.

Câu lệnh if dường như kiểm tra xem _param1 không phải là số không, và _param1 * _param2 không phải là số âm. Điều này có lẽ là để ngăn chặn các trường hợp tràn số (wrap around).

Cuối cùng, hàm trả về một giá trị đã được chia tỷ lệ.

claim

Mã mà trình dịch ngược tạo ra rất phức tạp và không phải tất cả đều liên quan đến chúng ta. Tôi sẽ bỏ qua một số phần để tập trung vào các dòng mà tôi tin là cung cấp thông tin hữu ích

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'

Chúng ta thấy ở đây hai điều quan trọng:

  • _param2, mặc dù được khai báo là uint256, nhưng thực chất là một địa chỉ
  • _param1 là cửa sổ (window) đang được yêu cầu nhận, phải là currentWindow hoặc sớm hơn.
  ...
  if stor5[_claimWindow][addr(_claimFor)]:
      revert with 0, 'Account already claimed the given window'

Vì vậy, bây giờ chúng ta biết rằng Storage[5] là một mảng các cửa sổ và địa chỉ, và liệu địa chỉ đó đã yêu cầu nhận phần thưởng cho cửa sổ đó hay chưa.

Chúng ta biết rằng unknown2eb4a7ab thực chất là hàm merkleRoot(), vì vậy đoạn mã này trông giống như nó đang xác minh một bằng chứng Merkle (opens in a new tab). Điều này có nghĩa là _param4 là một bằng chứng Merkle.

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

Đây là cách một hợp đồng chuyển ETH của chính nó sang một địa chỉ khác (hợp đồng hoặc tài khoản sở hữu bên ngoài). Nó gọi địa chỉ đó với một giá trị là số tiền cần chuyển. Vì vậy, có vẻ như đây là một đợt airdrop ETH.

  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

Hai dòng dưới cùng cho chúng ta biết rằng Storage[2] cũng là một hợp đồng mà chúng ta gọi. Nếu chúng ta nhìn vào giao dịch của hàm khởi tạo (opens in a new tab), chúng ta thấy rằng hợp đồng này là 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 (opens in a new tab), một hợp đồng Ether được bọc (WETH) có mã nguồn đã được tải lên Etherscan (opens in a new tab).

Vì vậy, có vẻ như hợp đồng cố gắng gửi ETH đến _param2. Nếu nó có thể làm được, thật tuyệt. Nếu không, nó cố gắng gửi WETH (opens in a new tab). Nếu _param2 là một tài khoản sở hữu bên ngoài (EOA) thì nó luôn có thể nhận ETH, nhưng các hợp đồng có thể từ chối nhận ETH. Tuy nhiên, WETH là ERC-20 và các hợp đồng không thể từ chối chấp nhận điều đó.

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

Ở cuối hàm, chúng ta thấy một mục nhật ký đang được tạo. Hãy xem các mục nhật ký được tạo (opens in a new tab) và lọc theo chủ đề bắt đầu bằng 0xdbd5.... Nếu chúng ta nhấp vào một trong các giao dịch đã tạo ra mục như vậy (opens in a new tab), chúng ta thấy rằng nó thực sự trông giống như một yêu cầu nhận - tài khoản đã gửi một thông điệp đến hợp đồng mà chúng ta đang dịch ngược, và đổi lại nhận được ETH.

A claim transaction

1e7df9d3

Hàm này rất giống với claim ở trên. Nó cũng kiểm tra một bằng chứng Merkle, cố gắng chuyển ETH cho người đầu tiên và tạo ra cùng một loại mục nhật ký.

Sự khác biệt chính là tham số đầu tiên, cửa sổ để rút tiền, không có ở đó. Thay vào đó, có một vòng lặp qua tất cả các cửa sổ có thể được yêu cầu nhận.

Vì vậy, nó trông giống như một biến thể claim yêu cầu nhận tất cả các cửa sổ.

Kết luận

Đến đây, bạn đã biết cách để hiểu các hợp đồng không có sẵn mã nguồn, bằng cách sử dụng các mã lệnh hoặc (khi nó hoạt động) trình dịch ngược. Như có thể thấy rõ từ độ dài của bài viết này, việc dịch ngược một hợp đồng không hề đơn giản, nhưng trong một hệ thống nơi bảo mật là yếu tố thiết yếu, thì khả năng xác minh các hợp đồng hoạt động đúng như cam kết là một kỹ năng quan trọng.

Xem thêm các bài viết khác của tôi tại đây (opens in a new tab).