Nhảy đến nội dung chính

Thiết kế ngược một hợp đồng

evm
mã vận hành
Nâng cao
Ori Pomerantz
30 tháng 12, 2021
39 số phút đọc

Giới thiệu

Không có bí mật nào trên chuỗi khối, mọi thứ xảy ra đều nhất quán, có thể kiểm chứng và có sẵn công khai. Lý tưởng nhất là các hợp đồng nên được công bố và xác minh mã nguồn trên Etherscan (opens in a new tab). Tuy nhiên, không phải lúc nào cũng vậy (opens in a new tab). Trong bài viết này, bạn sẽ học cách thiết kế ngược các hợp đồng bằng cách xem 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 thiết kế ngược thủ công và hiểu một hợp đồng từ các mã vận hành (opens in a new tab), cũng như cách diễn giải kết quả của một trình biên dịch ngược.

Để có thể hiểu bài viết này, bạn nên biết những điều cơ bản về EVM và ít nhất đã phần nào quen thuộc với trình hợp dịch EVM. Bạn có thể đọc về các chủ đề này ở đây (opens in a new tab).

Chuẩn bị mã thực thi

Bạn có thể lấy mã vận hành bằng cách truy cập Etherscan cho hợp đồng, nhấp vào tab Hợp đồng rồi Chuyển sang Chế độ xem Mã vận hành. Bạn sẽ có được chế độ xem là một mã vận hành trên mỗi dòng.

Chế độ xem Mã vận hành từ Etherscan

Tuy nhiên, để có thể hiểu các bước nhảy, bạn cần biết mỗi mã vận hành nằm ở đâu trong mã. Để làm điều đó, một cách là mở Google Spreadsheet và dán các mã vận hà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 đúng vị trí mã để chúng ta có thể hiểu được các bước nhảy. Chúng ta sẽ đặt kích thước mã vận hành vào cột B và vị trí (theo hệ thập lục phân) vào cột A. Nhập hàm này vào ô B1 rồi sao chép và dán vào phần còn lại của cột B, cho đến cuối mã. Sau khi thực hiện xong, bạn có thể ẩn cột B.

1=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ã vận hành, sau đó tìm kiếm PUSH. Mã vận hành đẩy là đặc biệt vì chúng cần có thêm các byte cho giá trị được đẩy. Nếu mã vận hành là PUSH, chúng ta sẽ trích xuất số lượng byte và cộng 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à một lần nữa sao chép và dán nó cho phần còn lại của cột A:

1=dec2hex(hex2dec(A1)+B1)

Chúng ta cần hàm này để cung cấp giá trị thập lục phân vì các giá trị được đẩy trước các bước nhảy (JUMPJUMPI) được cung cấp cho chúng ta dưới dạng 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ã:

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

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ữ trong 0x5F, và 0x40-0x5E đều bằng 0).
  2. Đọc kích thước calldata. Thông thường, dữ liệu cuộc gọi cho 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 cuộc gọi nhỏ hơn bốn, hãy nhảy đến 0x5E.

Lưu đồ cho phần này

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

Độ lệchMã vận hành
5EJUMPDEST
5FCALLDATASIZE
60PUSH2 0x007c
63JUMPI

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

Độ lệchMã vận hànhNgăn xếp (sau mã vận hà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
6BSLOADLưu trữ[6] CALLVALUE 0 6 CALLVALUE

Vì vậy, khi không có dữ liệu cuộc gọi, chúng ta sẽ đọc giá trị của Lưu trữ[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 được mà không có dữ liệu cuộc gọi. Các giao dịch chỉ chuyển ETH mà không có bất kỳ dữ liệu cuộc gọi nào (và do đó không có phương thức nào) có trong Etherscan phương thức Chuyển. 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 giao dịch chuyển.

Nếu chúng ta xem xét giao dịch đó và nhấp vào Nhấp để xem thêm, chúng ta sẽ thấy rằng dữ liệu cuộc 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ẽ có liên quan sau.

Dữ liệu cuộc gọi trống

Tiếp theo, nhấp vào tab Trạng thái và mở rộng hợp đồng mà chúng ta đang thiết kế ngược (0x2510...). Bạn có thể thấy rằng Lưu trữ[6] đã thay đổi trong giao dịch, và nếu bạn đổi Hex thành Số, bạn sẽ thấy nó trở thành 1.559.000.000.000.000.000, giá trị được chuyển 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.

Thay đổi trong Lưu trữ[6]

Nếu chúng ta xem xét các thay đổi trạng thái gây ra bởi các giao dịch Chuyển khác từ cùng thời kỳ (opens in a new tab), chúng ta thấy rằng Lưu trữ[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à Giá trị*. 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 sử dụng bộ nhớ, vốn rất tốn kém, khi bạn có thể nhận được số dư tài khoản của mình bằng cách sử dụng ĐỊA CHỈ SỐ DƯ. Mã vận hành đầu tiên đẩy địa chỉ riêng của hợp đồng. Mã thứ hai đọc địa chỉ ở đầu ngăn xếp và thay thế nó bằng số dư của địa chỉ đó.

Độ lệchMã vận hànhStack
6CPUSH2 0x00750x75 Giá trị* CALLVALUE 0 6 CALLVALUE
6FSWAP2CALLVALUE Giá trị* 0x75 0 6 CALLVALUE
70SWAP1Giá trị* CALLVALUE 0x75 0 6 CALLVALUE
71PUSH2 0x01a70x01A7 Giá trị* CALLVALUE 0x75 0 6 CALLVALUE
74JUMP

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

Độ lệchMã vận hànhStack
1A7JUMPDESTGiá trị* CALLVALUE 0x75 0 6 CALLVALUE
1A8PUSH1 0x000x00 Giá trị* CALLVALUE 0x75 0 6 CALLVALUE
1AADUP3CALLVALUE 0x00 Giá trị* CALLVALUE 0x75 0 6 CALLVALUE
1ABNOT2^256-CALLVALUE-1 0x00 Giá trị* CALLVALUE 0x75 0 6 CALLVALUE

NOT là theo bit, vì vậy nó đảo ngược giá trị của mọi bit trong giá trị cuộc gọi.

Độ lệchMã vận hànhStack
1ACDUP3Giá trị* 2^256-CALLVALUE-1 0x00 Giá trị* CALLVALUE 0x75 0 6 CALLVALUE
1ADGTGiá trị*>2^256-CALLVALUE-1 0x00 Giá trị* CALLVALUE 0x75 0 6 CALLVALUE
1AEISZEROGiá trị*<=2^256-CALLVALUE-1 0x00 Giá trị* CALLVALUE 0x75 0 6 CALLVALUE
1AFPUSH2 0x01df0x01DF Giá trị*<=2^256-CALLVALUE-1 0x00 Giá trị* CALLVALUE 0x75 0 6 CALLVALUE
1B2JUMPI

Chúng ta sẽ nhảy nếu Giá trị* nhỏ hơn hoặc bằng 2^256-CALLVALUE-1. Điều này có vẻ giống như logic để ngăn chặn tràn số. Và thực vậy, chúng ta thấy rằng sau một vài hoạt động vô nghĩa (ví dụ: ghi vào bộ nhớ sắp bị xóa) ở độ lệch 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 một sự tràn số như vậy là cực kỳ khó xảy ra, bởi vì nó sẽ yêu cầu giá trị cuộc gọi cộng với Giá trị* phải tương đương với 2^256 wei, khoảng 10^59 ETH. Tổng cung ETH, tại thời điểm viết bài, là dưới hai trăm triệu (opens in a new tab).

Độ lệchMã vận hànhStack
1DFJUMPDEST0x00 Giá trị* CALLVALUE 0x75 0 6 CALLVALUE
1E0POPGiá trị* CALLVALUE 0x75 0 6 CALLVALUE
1E1ADDGiá trị*+CALLVALUE 0x75 0 6 CALLVALUE
1E2SWAP10x75 Giá trị*+CALLVALUE 0 6 CALLVALUE
1E3JUMP

Nếu chúng ta đến đây, hãy lấy Giá trị* + CALLVALUE và nhảy đến độ lệch 0x75.

Độ lệchMã vận hànhStack
75JUMPDESTGiá trị*+CALLVALUE 0 6 CALLVALUE
76SWAP10 Giá trị*+CALLVALUE 6 CALLVALUE
77SWAP26 Giá trị*+CALLVALUE 0 CALLVALUE
78SSTORE0 CALLVALUE

Nếu chúng ta đến đây (yêu cầu dữ liệu cuộc gọi phải trống), chúng ta sẽ cộng giá trị cuộc gọi vào Giá trị*. Điều này phù hợp với những gì chúng ta nói về giao dịch Chuyển.

Độ lệchMã vận hà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 thành công giao dịch.

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

Lưu đồ điểm vào

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 thực hiện. Vấn đề không phải là dạy bạn cách hoạt động của hợp đồng cụ thể này, mà là cách để thiết kế ngược các hợp đồng. Bạn sẽ học được những gì nó làm theo cách tương tự như tôi đã làm, bằng cách theo dõi mã.

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

  • Nếu có dữ liệu cuộc gọi 1, 2 hoặc 3 byte (từ độ lệch 0x63)
  • Nếu chữ ký phương thức không xác định (từ độ lệch 0x42 và 0x5D)
Độ lệchMã vận hànhStack
7CJUMPDEST
7DPUSH1 0x000x00
7FPUSH2 0x009d0x9D 0x00
82PUSH1 0x030x03 0x9D 0x00
84SLOADLưu trữ[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 ý nghĩa của nó hơn. Mã dưới đây sẽ làm cho nó rõ ràng hơn.

Độ lệchMã vận hànhStack
85PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xff....ff Lưu trữ[3] 0x9D 0x00
9AANDLưu trữ[3]-dưới dạng-địa chỉ 0x9D 0x00

Các mã vận hành này cắt bớt giá trị chúng ta đọc từ Lưu trữ[3] thành 160 bit, độ dài của một địa chỉ Ethereum.

Độ lệchMã vận hànhStack
9BSWAP10x9D Lưu trữ[3]-dưới dạng-địa chỉ 0x00
9CJUMPLưu trữ[3]-dưới dạng-địa chỉ 0x00

Bước nhảy này là thừa, vì chúng ta sẽ đi đến mã vận hành tiếp theo. Mã này không hiệu quả về gas như nó có thể.

Độ lệchMã vận hànhStack
9DJUMPDESTLưu trữ[3]-dưới dạng-địa chỉ 0x00
9ESWAP10x00 Lưu trữ[3]-dưới dạng-địa chỉ
9FPOPLưu trữ[3]-dưới dạng-địa chỉ
A0PUSH1 0x400x40 Lưu trữ[3]-dưới dạng-địa chỉ
A2MLOADMem[0x40] Lưu trữ[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 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.

Độ lệchMã vận hànhStack
A3CALLDATASIZECALLDATASIZE 0x80 Lưu trữ[3]-dưới dạng-địa chỉ
A4PUSH1 0x000x00 CALLDATASIZE 0x80 Lưu trữ[3]-dưới dạng-địa chỉ
A6DUP30x80 0x00 CALLDATASIZE 0x80 Lưu trữ[3]-dưới dạng-địa chỉ
A7CALLDATACOPY0x80 Lưu trữ[3]-dưới dạng-địa chỉ

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

Độ lệchMã vận hànhStack
A8PUSH1 0x000x00 0x80 Lưu trữ[3]-dưới dạng-địa chỉ
AADUP10x00 0x00 0x80 Lưu trữ[3]-dưới dạng-địa chỉ
ABCALLDATASIZECALLDATASIZE 0x00 0x00 0x80 Lưu trữ[3]-dưới dạng-địa chỉ
ACDUP40x80 CALLDATASIZE 0x00 0x00 0x80 Lưu trữ[3]-dưới dạng-địa chỉ
ADDUP6Lưu trữ[3]-dưới dạng-địa chỉ 0x80 CALLDATASIZE 0x00 0x00 0x80 Lưu trữ[3]-dưới dạng-địa chỉ
AEGASGAS Lưu trữ[3]-dưới dạng-địa chỉ 0x80 CALLDATASIZE 0x00 0x00 0x80 Lưu trữ[3]-dưới dạng-địa chỉ
AFDELEGATE_CALL

Bây giờ mọi thứ đã rõ ràng hơn rất nhiều. Hợp đồng này có thể hoạt động như một proxy (opens in a new tab), gọi địa chỉ trong Lưu trữ[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 bộ 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à proxy, truy cập vào cùng một không gian lưu trữ. Các thông số cho cuộc gọi là:

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

Tại đây, chúng tôi sao chép tất cả dữ liệu trả về vào bộ đệm bộ nhớ bắt đầu từ 0x80.

Độ lệchMã vận hànhStack
B6DUP2(((gọi thành công/thất bại))) RETURNDATASIZE (((gọi thành công/thất bại))) 0x80 Lưu trữ[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 Lưu trữ[3]-dưới dạng-địa chỉ
B8ISZERO(((cuộc gọi có thất bại không))) (((cuộc gọi thành công/thất bại))) RETURNDATASIZE (((cuộc gọi thành công/thất bại))) 0x80 Lưu trữ[3]-dưới dạng-địa chỉ
B9PUSH2 0x00c00xC0 (((cuộc gọi có thất bại không))) (((cuộc gọi thành công/thất bại))) RETURNDATASIZE (((cuộc gọi thành công/thất bại))) 0x80 Lưu trữ[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 Lưu trữ[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 Lưu trữ[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 Lưu trữ[3]-dưới dạng-địa chỉ
BFRETURN

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

DELEGATECALL thất bại

Nếu chúng ta đến đây, đến 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 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.

Độ lệchMã vận hànhStack
C0JUMPDEST(((gọi thành công/thất bại))) RETURNDATASIZE (((gọi thành công/thất bại))) 0x80 Lưu trữ[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 Lưu trữ[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 Lưu trữ[3]-dưới dạng-địa chỉ
C3REVERT

Vì vậy, chúng ta HOÀN NGUYÊN với cùng một bộ đệm mà chúng ta đã sử dụng cho TRẢ VỀ trước đó: 0x80 - 0x80+RETURNDATASIZE

Lưu đồ cuộc gọi đến proxy

Các lệnh gọi ABI

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

Độ lệchMã vận hànhStack
DPUSH1 0x000x00
FCALLDATALOAD(((Từ đầu tiên (256 bit) của dữ liệu cuộc gọi)))
10PUSH1 0xe00xE0 (((Từ đầu tiên (256 bit) của dữ liệu cuộc gọi)))
12SHR(((32 bit đầu tiên (4 byte) của dữ liệu cuộc gọi)))

Etherscan cho chúng ta biết rằng 1C là một mã vận hà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ã vận hành cập nhật (opens in a new tab) cho chúng ta thấy đây là dịch phải

Độ lệchMã vận hànhStack
13DUP1(((32 bit đầu tiên (4 byte) của dữ liệu cuộc gọi))) (((32 bit đầu tiên (4 byte) của dữ liệu cuộc gọi)))
14PUSH4 0x3cd8045e0x3CD8045E (((32 bit đầu tiên (4 byte) của dữ liệu cuộc gọi))) (((32 bit đầu tiên (4 byte) của dữ liệu cuộc gọi)))
19GT0x3CD8045E>dữ liệu-cuộc-gọi-32-bit-đầu tiên (((32 bit đầu tiên (4 byte) của dữ liệu cuộc gọi)))
1APUSH2 0x00430x43 0x3CD8045E>dữ liệu-cuộc-gọi-32-bit-đầu tiên (((32 bit đầu tiên (4 byte) của dữ liệu cuộc gọi)))
1DJUMPI(((32 bit đầu tiên (4 byte) của dữ liệu cuộc gọi)))

Bằng cách chia các bài kiểm tra khớp chữ ký phương thức thành hai như thế này, trung bình sẽ tiết kiệm được một nửa số bài kiểm tra. Mã ngay sau mã này và mã trong 0x43 tuân theo cùng một mẫu: DUP1 32 bit đầu tiên của dữ liệu cuộc gọi, PUSH4 (((chữ ký phương thức), chạy EQ để kiểm tra sự bằng nhau, và sau đó là 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 phápChữ ký phương thứcĐộ lệch để nhảy vào
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ả phù hợp, mã sẽ chuyển đến trình xử lý proxy tại 0x7C, với hy vọng rằng hợp đồng mà chúng tôi là proxy có kết quả phù hợp.

Lưu đồ các lệnh gọi ABI

splitter()

Độ lệchMã vận hànhStack
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 mà hàm này thực hiện là kiểm tra xem lệnh gọi có gửi bất kỳ ETH nào 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 tôi, đó phải là một sai lầm và chúng tôi muốn HOÀN NGUYÊN để tránh việc ETH đó ở nơi họ không thể lấy lại được.

Độ lệchMã vận hànhStack
10FJUMPDEST
110POP
111PUSH1 0x030x03
113SLOAD(((Lưu trữ[3] hay còn gọi là hợp đồng mà chúng tôi làm proxy)))
114PUSH1 0x400x40 (((Lưu trữ[3] hay còn gọi là hợp đồng mà chúng tôi làm proxy)))
116MLOAD0x80 (((Lưu trữ[3] hay còn gọi là hợp đồng mà chúng tôi làm proxy)))
117PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xFF...FF 0x80 (((Lưu trữ[3] hay còn gọi là hợp đồng mà chúng tôi làm proxy)))
12CSWAP10x80 0xFF...FF (((Lưu trữ[3] hay còn gọi là hợp đồng mà chúng tôi làm proxy)))
12DSWAP2(((Lưu trữ[3] hay còn gọi là hợp đồng mà chúng tôi làm proxy))) 0xFF...FF 0x80
12EANDĐịa chỉ proxy 0x80
12FDUP20x80 Địa chỉ proxy 0x80
130MSTORE0x80

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

Độ lệchMã vận hànhStack
131PUSH1 0x200x20 0x80
133ADD0xA0
134PUSH2 0x00e40xE4 0xA0
137JUMP0xA0

Mã E4

Đây là lần đầu tiên chúng tôi 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.

Độ lệchMã vận hànhStack
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à làm cho hợp đồng TRẢ VỀ 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 tôi là một proxy. RETURN trả về bộ đệm trong 0x80-0x9F, đây là nơi chúng tôi đã viết dữ liệu này (độ lệch 0x130 ở trên).

currentWindow()

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

Độ lệchMã vận hànhStack
164JUMPDEST
165POP
166PUSH2 0x00da0xDA
169PUSH1 0x010x01 0xDA
16BSLOADLưu trữ[1] 0xDA
16CDUP20xDA Lưu trữ[1] 0xDA
16DJUMPLưu trữ[1] 0xDA

Mã DA

Mã này cũng được chia sẻ với các phương pháp 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à Lưu trữ[1].

Độ lệchMã vận hànhStack
DAJUMPDESTY 0xDA
DBPUSH1 0x400x40 Y 0xDA
DDMLOAD0x80 Y 0xDA
DESWAP1Y 0x80 0xDA
DFDUP20x80 Y 0x80 0xDA
E0MSTORE0x80 0xDA

Viết Y vào 0x80-0x9F.

Độ lệchMã vận hànhStack
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 giá trị trên cùng của 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ề Lưu trữ[1].

merkleRoot()

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

Độ lệchMã vận hànhStack
F9JUMPDEST
FAPOP
FBPUSH2 0x00da0xDA
FEPUSH1 0x000x00 0xDA
100SLOADLưu trữ[0] 0xDA
101DUP20xDA Lưu trữ[0] 0xDA
102JUMPLưu trữ[0] 0xDA

Điều gì xảy ra sau khi nhảy chúng ta đã tìm ra. Vì vậy, merkleRoot() trả về Lưu trữ[0].

0x81e580d3

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

Độ lệchMã vận hà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 từ) dữ liệu cuộc gọi.

Độ lệchMã vận hànhStack
19DDUP10x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19EDUP20x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19FREVERT

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

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

Độ lệchMã vận hànhStack
1A0JUMPDEST0x00 0x04 CALLDATASIZE 0x0153 0xDA
1A1POP0x04 CALLDATASIZE 0x0153 0xDA
1A2CALLDATALOADcalldataload(4) CALLDATASIZE 0x0153 0xDA

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

Độ lệchMã vận hà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
173SLOADLưu trữ[4] calldataload(4) 0x04 calldataload(4) 0xDA
174DUP2calldataload(4) Lưu trữ[4] calldataload(4) 0x04 calldataload(4) 0xDA
175LTcalldataload(4)<Lưu trữ[4] calldataload(4) 0x04 calldataload(4) 0xDA
176PUSH2 0x017e0x017EC calldataload(4)<Lưu trữ[4] calldataload(4) 0x04 calldataload(4) 0xDA
179JUMPIcalldataload(4) 0x04 calldataload(4) 0xDA

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

Độ lệchMã vận hànhStack
17APUSH1 0x000x00 ...
17CDUP10x00 0x00 ...
17DREVERT

Nếu calldataload(4) nhỏ hơn Lưu trữ[4], chúng ta sẽ nhận được mã này:

Độ lệchMã vận hà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 hiện chứa dữ liệu 0x04 (0x00-0x1E đều là số không, 0x1F là bốn)

Độ lệchMã vận hà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 của 0x00-0x1F))) calldataload(4) calldataload(4) 0xDA
189ADD(((SHA3 của 0x00-0x1F)))+calldataload(4) calldataload(4) 0xDA
18ASLOADLưu trữ[(((SHA3 của 0x00-0x1F))) + calldataload(4)] calldataload(4) 0xDA

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

Độ lệchMã vận hànhStack
18BSWAP1calldataload(4) Lưu trữ[(((SHA3 của 0x00-0x1F))) + calldataload(4)] 0xDA
18CPOPLưu trữ[(((SHA3 của 0x00-0x1F))) + calldataload(4)] 0xDA
18DDUP20xDA Lưu trữ[(((SHA3 của 0x00-0x1F))) + calldataload(4)] 0xDA
18EJUMPLưu trữ[(((SHA3 của 0x00-0x1F))) + calldataload(4)] 0xDA

Chúng ta đã biết mã tại độ lệch 0xDA làm gì, nó trả về giá trị trên cùng của ngăn xếp 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ã ở độ lệch 0xC4-0xCF giống hệt với những gì chúng ta đã thấy ở 0x103-0x10E trong splitter() (ngoài đích JUMPI), vì vậy chúng ta biết hàm này cũng không phải là payable.

Độ lệchMã vận hànhStack
D0JUMPDEST
D1POP
D2PUSH2 0x00da0xDA
D5PUSH1 0x060x06 0xDA
D7SLOADGiá trị* 0xDA
D8DUP20xDA Giá trị* 0xDA
D9JUMPGiá trị* 0xDA

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

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

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

Phương phápÝ nghĩa
Chuyển khoảnChấp nhận giá trị được cung cấp bởi lệnh gọi và tăng Giá trị* theo số tiền đó
splitter()Trả về Lưu trữ[3], địa chỉ proxy
currentWindow()Trả về Lưu trữ[1]
merkleRoot()Trả về Lưu trữ[0]
0x81e580d3Trả về giá trị từ bảng tra cứu, với điều kiện tham số nhỏ hơn Lưu trữ[4]
0x1f135823Trả về Lưu trữ[6], hay còn gọi là Giá trị*

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 Lưu trữ[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à trên lý thuyết. Chúng tôi không thấy bất kỳ phương pháp nào đặt Lưu trữ[3], vì vậy nó phải được đặt bởi hàm tạo.

Hàm dựng

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ó.

Nhấp vào giao dịch tạo

Nếu chúng ta nhấp vào giao dịch đó, rồi nhấp vào 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 Lưu trữ[3] chứa 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761 (opens in a new tab). Hợp đồng đó phải 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ác công cụ tương tự mà chúng ta đã sử dụng cho hợp đồng mà chúng ta đang điều tra.

Hợp đồng Proxy

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

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

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

Chúng ta có thể bỏ qua bốn phương pháp dưới cùng vì chúng ta sẽ không bao giờ đến được chúng. Chữ ký của chúng là như vậy mà hợp đồng ban đầu 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 phải là các phương thức được 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 từng mã vận hành một, chúng ta có thể thử trình biên dịch ngược (opens in a new tab), trình biên dịch này tạo ra kết quả có thể sử dụng được cho ba hàm từ hợp đồng này. Việc thiết kế ngược các phần còn lại được để lại như một bài tập cho người đọc.

scaleAmountByPercentage

Đây là những gì trình biên dịch ngược cung cấp cho chúng ta cho hàm này:

1def unknown8ffb5c97(uint256 _param1, uint256 _param2) payable:
2 yêu cầu calldata.size - 4 >=64
3 nếu _param1 và _param2 > -1 / _param1:
4 hoàn nguyên với 0, 17
5 return (_param1 * _param2 / 100 * 10^6)

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

Câu lệnh if dường như kiểm tra rằng _param1 không phải là không, và _param1 * _param2 không phải là số âm. Nó có lẽ để ngăn chặn các trường hợp tràn số.

Cuối cùng, hàm trả về một giá trị đã được thay đổi quy mô.

claim

Mã mà trình biên 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ố dòng để tập trung vào những dòng mà tôi tin là cung cấp thông tin hữu ích

1def unknown2e7ba6ef(uint256 _param1, uint256 _param2, uint256 _param3, array _param4) payable:
2 ...
3 yêu cầu _param2 == addr(_param2)
4 ...
5 nếu currentWindow <= _param1:
6 hoàn nguyên với 0, 'không thể yêu cầu cho một cửa sổ trong tương lai'

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

  • _param2, mặc dù nó được khai báo là uint256, thực ra là một địa chỉ
  • _param1 là cửa sổ đang được yêu cầu, phải là currentWindow hoặc sớm hơn.
1 ...
2 if stor5[_claimWindow][addr(_claimFor)]:
3 revert with 0, 'Tài khoản đã yêu cầu cửa sổ đã cho'

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

1 ...
2 idx = 0
3 s = 0
4 trong khi idx < _param4.length:
5 ...
6 nếu s + sha3(mem[(32 * _param4.length) + 328 len mem[(32 * _param4.length) + 296]]) > mem[(32 * idx) + 296]:
7 mem[mem[64] + 32] = mem[(32 * idx) + 296]
8 ...
9 s = sha3(mem[_62 + 32 len mem[_62]])
10 tiếp tục
11 ...
12 s = sha3(mem[_66 + 32 len mem[_66]])
13 tiếp tục
14 nếu unknown2eb4a7ab != s:
15 hoàn nguyên với 0, 'Bằng chứng không hợp lệ'
Hiện tất cả

Chúng ta biết rằng unknown2eb4a7ab thực ra là hàm merkleRoot(), vì vậy mã này trông giống như đ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.

1 gọi addr(_param2) với:
2 giá trị không xác định81e580d3[_param1] * _param3 / 100 * 10^6 wei
3 gas 30000 wei

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

1 nếu không return_data.size:
2 if not ext_call.success:
3 require ext_code.size(stor2)
4 call stor2.deposit() với:
5 value không xác định81e580d3[_param1] * _param3 / 100 * 10^6 wei

Hai dòng dưới cùng cho chúng ta biết rằng Lưu trữ[2] cũng là một hợp đồng mà chúng ta gọi. Nếu chúng ta xem xét giao dịch hàm 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 Wrapped Ether có mã nguồn đã được tải lên Etherscan (opens in a new tab).

Vì vậy, có vẻ như các hợp đồng cố gắng gửi ETH đến _param2. Nếu nó có thể làm được, tuyệt vời. 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 đó.

1 ...
2 log 0xdbd5389f: addr(_param2), không xác định81e580d3[_param1] * _param3 / 100 * 10^6, bool(ext_call.success)

Ở cuối hàm, chúng ta thấy một mục nhập nhật ký đang được tạo. Xem các mục nhập nhật ký đã 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ột mục nhập như vậy (opens in a new tab), chúng ta thấy rằng thực sự nó trông giống như một yêu cầu - tài khoản đã gửi một thông điệp đến hợp đồng mà chúng ta đang thiết kế ngược, và đổi lại nhận được ETH.

Giao dịch yêu cầu

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ập nhật ký.

1def unknown1e7df9d3(uint256 _param1, uint256 _param2, array _param3) payable:
2 ...
3 idx = 0
4 s = 0
5 while idx < _param3.length:
6 if idx >= mem[96]:
7 revert with 0, 50
8 _55 = mem[(32 * idx) + 128]
9 if s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]]) > mem[(32 * idx) + 128]:
10 ...
11 s = sha3(mem[_58 + 32 len mem[_58]])
12 tiếp tục
13 mem[mem[64] + 32] = s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]])
14 ...
15 if unknown2eb4a7ab != s:
16 revert with 0, 'Bằng chứng không hợp lệ'
17 ...
18 call addr(_param1) với:
19 value s wei
20 gas 30000 wei
21 if not return_data.size:
22 if not ext_call.success:
23 require ext_code.size(stor2)
24 call stor2.deposit() với:
25 value s wei
26 gas gas_remaining wei
27 ...
28 log 0xdbd5389f: addr(_param1), s, bool(ext_call.success)
Hiện tất cả

Sự khác biệt chính là tham số đầu tiên, cửa sổ để rút, 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.

1 idx = 0
2 s = 0
3 trong khi idx < currentWindow:
4 ...
5 if stor5[mem[0]]:
6 nếu idx == -1:
7 hoàn nguyên với 0, 17
8 idx = idx + 1
9 s = s
10 tiếp tục
11 ...
12 stor5[idx][addr(_param1)] = 1
13 if idx >= unknown81e580d3.length:
14 revert with 0, 50
15 mem[0] = 4
16 if unknown81e580d3[idx] and _param2 > -1 / unknown81e580d3[idx]:
17 revert with 0, 17
18 if s > !(unknown81e580d3[idx] * _param2 / 100 * 10^6):
19 revert with 0, 17
20 if idx == -1:
21 revert with 0, 17
22 idx = idx + 1
23 s = s + (unknown81e580d3[idx] * _param2 / 100 * 10^6)
24 tiếp tục
Hiện tất cả

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

Kết luận

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

Xem thêm công việc của tôi tại đây (opens in a new tab).

Lần cập nhật trang lần cuối: 22 tháng 8, 2025

Hướng dẫn này có hữu ích không?