메인 콘텐츠로 건너뛰기

계약 리버스 엔지니어링

evm
연산 부호
심화 주제
Ori Pomerantz
2021년 12월 30일
45 1분의 읽기 소요시간

소개

블록체인에는 비밀이 없으며, 발생하는 모든 일은 일관되고 검증 가능하며 공개적으로 이용 가능합니다. 이상적으로 계약은 소스 코드를 Etherscan에 게시하고 확인해야 합니다 (opens in a new tab). 그러나 항상 그런 것은 아닙니다 (opens in a new tab). 이 글에서는 소스 코드 없이 0x2510c039cc3b061d79e564b38836da87e31b342f (opens in a new tab) 계약을 살펴봄으로써 계약을 리버스 엔지니어링하는 방법을 배웁니다.

리버스 컴파일러가 있지만 항상 사용 가능한 결과 (opens in a new tab)를 생성하는 것은 아닙니다. 이 글에서는 연산 부호 (opens in a new tab)에서 계약을 수동으로 리버스 엔지니어링하고 이해하는 방법과 디컴파일러의 결과를 해석하는 방법을 배웁니다.

이 글을 이해하려면 EVM의 기본 사항을 이미 알고 있어야 하며 EVM 어셈블러에 어느 정도 익숙해야 합니다. 여기에서 이러한 주제에 대해 읽을 수 있습니다 (opens in a new tab).

실행 코드 준비

Etherscan에서 계약으로 이동하여 계약 탭을 클릭한 다음 연산 부호 보기로 전환을 클릭하여 연산 부호를 얻을 수 있습니다. 한 줄에 하나의 연산 부호가 표시되는 보기를 얻습니다.

Etherscan의 연산 부호 보기

그러나 점프를 이해하려면 각 연산 부호가 코드의 어디에 있는지 알아야 합니다. 그러기 위한 한 가지 방법은 구글 스프레드시트를 열고 C열에 연산 부호를 붙여넣는 것입니다. 이미 준비된 스프레드시트의 사본을 만들어 다음 단계를 건너뛸 수 있습니다 (opens in a new tab).

다음 단계는 점프를 이해할 수 있도록 올바른 코드 위치를 얻는 것입니다. B열에 연산 부호 크기를, A열에 위치(16진수)를 넣습니다. B1 셀에 이 함수를 입력한 다음 코드 끝까지 나머지 B열에 복사하여 붙여넣습니다. 이 작업을 마친 후 B열을 숨길 수 있습니다.

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

먼저 이 함수는 연산 부호 자체에 1바이트를 추가한 다음 PUSH를 찾습니다. 푸시 연산 부호는 푸시되는 값에 대한 추가 바이트가 필요하기 때문에 특별합니다. 연산 부호가 PUSH인 경우 바이트 수를 추출하여 추가합니다.

A1에 첫 번째 오프셋인 0을 넣습니다. 그런 다음 A2에 이 함수를 넣고 나머지 A열에 다시 복사하여 붙여넣습니다.

1=dec2hex(hex2dec(A1)+B1)

점프(JUMPJUMPI) 전에 푸시되는 값이 16진수로 주어지기 때문에 16진수 값을 얻으려면 이 함수가 필요합니다.

진입점(0x00)

계약은 항상 첫 번째 바이트부터 실행됩니다. 이는 코드의 초기 부분입니다:

오프셋연산 부호스택(연산 부호 후)
0PUSH1 0x800x80
2PUSH1 0x400x40, 0x80
4MSTORE비어 있음
5PUSH1 0x040x04
7CALLDATASIZECALLDATASIZE 0x04
8LTCALLDATASIZE<4
9PUSH2 0x005e0x5E CALLDATASIZE<4
CJUMPI비어 있음

이 코드는 두 가지 작업을 수행합니다.

  1. 0x80을 32바이트 값으로 메모리 위치 0x40-0x5F에 씁니다(0x80은 0x5F에 저장되고 0x40-0x5E는 모두 0입니다).
  2. 호출 데이터 크기를 읽습니다. 일반적으로 이더리움 계약의 호출 데이터는 ABI(애플리케이션 바이너리 인터페이스) (opens in a new tab)를 따르며, 함수 선택기에 최소 4바이트가 필요합니다. 호출 데이터 크기가 4보다 작으면 0x5E로 점프합니다.

이 부분에 대한 순서도

0x5E의 핸들러(비 ABI 호출 데이터용)

오프셋연산 부호
5EJUMPDEST
5FCALLDATASIZE
60PUSH2 0x007c
63JUMPI

이 스니펫은 JUMPDEST로 시작합니다. EVM(이더리움 가상 머신) 프로그램은 JUMPDEST가 아닌 연산 부호로 점프하면 예외를 발생시킵니다. 그런 다음 CALLDATASIZE를 보고 "true"(즉, 0이 아님)이면 0x7C로 점프합니다. 아래에서 살펴보겠습니다.

오프셋연산 부호스택(연산 부호 후)
64CALLVALUE호출에서 제공하는 . Solidity에서는 msg.value라고 합니다
65PUSH1 0x066 CALLVALUE
67PUSH1 0x000 6 CALLVALUE
69DUP3CALLVALUE 0 6 CALLVALUE
6ADUP36 CALLVALUE 0 6 CALLVALUE
6BSLOADStorage[6] CALLVALUE 0 6 CALLVALUE

따라서 호출 데이터가 없으면 Storage[6]의 값을 읽습니다. 이 값이 무엇인지는 아직 모르지만, 계약이 호출 데이터 없이 수신한 트랜잭션을 찾아볼 수 있습니다. 호출 데이터 없이(따라서 메서드 없이) ETH만 전송하는 트랜잭션은 Etherscan에 Transfer 메서드가 있습니다. 실제로 계약이 받은 첫 번째 트랜잭션 (opens in a new tab)은 전송입니다.

해당 트랜잭션을 살펴보고 더 보려면 클릭을 클릭하면 입력 데이터라고 하는 호출 데이터가 실제로 비어 있음(0x)을 알 수 있습니다. 값이 1.559ETH라는 점도 주목하십시오. 이는 나중에 관련이 있습니다.

호출 데이터가 비어 있습니다

다음으로 상태 탭을 클릭하고 리버스 엔지니어링 중인 계약(0x2510...)을 확장합니다. Storage[6]이 트랜잭션 중에 변경되었음을 볼 수 있으며, 16진수를 숫자로 변경하면 1,559,000,000,000,000,000, 즉 wei로 전송된 값(명확성을 위해 쉼표를 추가함)이 되어 다음 계약 값에 해당함을 볼 수 있습니다.

Storage[6]의 변경 사항

동일한 기간의 다른 전송 트랜잭션으로 인한 상태 변경 (opens in a new tab)을 살펴보면 Storage[6]이 한동안 계약의 가치를 추적했음을 알 수 있습니다. 지금은 Value*라고 부르겠습니다. 별표(*)는 이 변수가 아직 무엇을 하는지 모른다는 것을 상기시켜 주지만, ADDRESS BALANCE를 사용하여 계정 잔액을 얻을 수 있을 때 매우 비싼 저장 공간을 사용할 필요가 없기 때문에 계약 값을 추적하는 것만으로는 충분하지 않습니다. 첫 번째 연산 부호는 계약 자체의 주소를 푸시합니다. 두 번째는 스택 맨 위에 있는 주소를 읽고 해당 주소의 잔액으로 바꿉니다.

오프셋연산 부호스택
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

점프 대상에서 이 코드를 계속 추적할 것입니다.

오프셋연산 부호스택
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은 비트 단위이므로 호출 값의 모든 비트 값을 반전시킵니다.

오프셋연산 부호스택
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보다 작거나 같으면 점프합니다. 이는 오버플로우를 방지하기 위한 로직으로 보입니다. 실제로 몇 가지 무의미한 작업(예: 메모리에 쓰기 직전에 삭제됨) 후 오프셋 0x01DE에서 오버플로우가 감지되면 계약이 되돌려지며, 이는 정상적인 동작입니다.

이러한 오버플로우는 호출 값에 Value*를 더한 값이 2^256 wei(약 10^59 ETH)에 필적해야 하므로 발생할 가능성이 매우 낮습니다. 작성 시점의 총 ETH 공급량은 2억 미만입니다 (opens in a new tab).

오프셋연산 부호스택
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

여기에 도착하면 Value* + CALLVALUE를 가져와 오프셋 0x75로 점프합니다.

오프셋연산 부호스택
75JUMPDESTValue*+CALLVALUE 0 6 CALLVALUE
76SWAP10 Value*+CALLVALUE 6 CALLVALUE
77SWAP26 Value*+CALLVALUE 0 CALLVALUE
78SSTORE0 CALLVALUE

여기에 도착하면(호출 데이터가 비어 있어야 함) Value*에 호출 값을 추가합니다. 이는 전송 트랜잭션이 수행하는 작업과 일치합니다.

오프셋연산 부호
79POP
7APOP
7BSTOP

마지막으로 스택을 지우고(필수는 아님) 트랜잭션이 성공적으로 끝났음을 알립니다.

요약하자면, 다음은 초기 코드에 대한 순서도입니다.

진입점 순서도

0x7C의 핸들러

이 핸들러가 무엇을 하는지 제목에 일부러 넣지 않았습니다. 요점은 이 특정 계약이 어떻게 작동하는지 가르치는 것이 아니라 계약을 리버스 엔지니어링하는 방법을 가르치는 것입니다. 코드를 따라 제가 했던 것과 같은 방식으로 코드가 무엇을 하는지 배울 것입니다.

우리는 여러 곳에서 여기에 도착합니다:

  • 1, 2 또는 3바이트의 호출 데이터가 있는 경우(오프셋 0x63에서)
  • 메서드 서명을 알 수 없는 경우(오프셋 0x42 및 0x5D에서)
오프셋연산 부호스택
7CJUMPDEST
7DPUSH1 0x000x00
7FPUSH2 0x009d0x9D 0x00
82PUSH1 0x030x03 0x9D 0x00
84SLOADStorage[3] 0x9D 0x00

이것은 또 다른 저장 공간 셀이며, 어떤 트랜잭션에서도 찾을 수 없었기 때문에 무엇을 의미하는지 알기가 더 어렵습니다. 아래 코드를 보면 더 명확해질 것입니다.

오프셋연산 부호스택
85PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xff....ff Storage[3] 0x9D 0x00
9AANDStorage[3]-as-address 0x9D 0x00

이 연산 부호는 Storage[3]에서 읽은 값을 이더리움 주소의 길이인 160비트로 자릅니다.

오프셋연산 부호스택
9BSWAP10x9D Storage[3]-as-address 0x00
9CJUMPStorage[3]-as-address 0x00

다음 연산 부호로 이동하므로 이 점프는 불필요합니다. 이 코드는 가능한 만큼 가스 효율적이지 않습니다.

오프셋연산 부호스택
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

코드 맨 처음에 Mem[0x40]을 0x80으로 설정했습니다. 나중에 0x40을 찾아보면 변경하지 않은 것을 알 수 있으므로 0x80이라고 가정할 수 있습니다.

오프셋연산 부호스택
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

모든 호출 데이터를 0x80부터 시작하여 메모리에 복사합니다.

오프셋연산 부호스택
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

이제 훨씬 명확해졌습니다. 이 계약은 프록시 (opens in a new tab) 역할을 하여 Storage[3]의 주소를 호출하여 실제 작업을 수행할 수 있습니다. DELEGATE_CALL은 별도의 계약을 호출하지만 동일한 저장 공간에 머물러 있습니다. 이는 우리가 프록시인 위임된 계약이 동일한 저장 공간 공간에 액세스한다는 것을 의미합니다. 호출 매개 변수는 다음과 같습니다.

  • 가스: 남은 모든 가스
  • 호출된 주소: Storage[3]-as-address
  • 호출 데이터: 0x80에서 시작하는 CALLDATASIZE 바이트. 여기에 원래 호출 데이터를 넣습니다.
  • 반환 데이터: 없음(0x00 - 0x00) 다른 방법으로 반환 데이터를 얻을 것입니다(아래 참조).
오프셋연산 부호스택
B0RETURNDATASIZERETURNDATASIZE (((호출 성공/실패))) 0x80 Storage[3]-as-address
B1DUP1RETURNDATASIZE RETURNDATASIZE (((호출 성공/실패))) 0x80 Storage[3]-as-address
B2PUSH1 0x000x00 RETURNDATASIZE RETURNDATASIZE (((호출 성공/실패))) 0x80 Storage[3]-as-address
B4DUP50x80 0x00 RETURNDATASIZE RETURNDATASIZE (((호출 성공/실패))) 0x80 Storage[3]-as-address
B5RETURNDATACOPYRETURNDATASIZE (((호출 성공/실패))) 0x80 Storage[3]-as-address

여기에서 모든 반환 데이터를 0x80에서 시작하는 메모리 버퍼에 복사합니다.

오프셋연산 부호스택
B6DUP2(((호출 성공/실패))) RETURNDATASIZE (((호출 성공/실패))) 0x80 Storage[3]-as-address
B7DUP1(((호출 성공/실패))) (((호출 성공/실패))) RETURNDATASIZE (((호출 성공/실패))) 0x80 Storage[3]-as-address
B8ISZERO(((호출에 실패했습니까))) (((호출 성공/실패))) RETURNDATASIZE (((호출 성공/실패))) 0x80 Storage[3]-as-address
B9PUSH2 0x00c00xC0 (((호출에 실패했습니까))) (((호출 성공/실패))) RETURNDATASIZE (((호출 성공/실패))) 0x80 Storage[3]-as-address
BCJUMPI(((호출 성공/실패))) RETURNDATASIZE (((호출 성공/실패))) 0x80 Storage[3]-as-address
BDDUP2RETURNDATASIZE (((호출 성공/실패))) RETURNDATASIZE (((호출 성공/실패))) 0x80 Storage[3]-as-address
BEDUP50x80 RETURNDATASIZE (((호출 성공/실패))) RETURNDATASIZE (((호출 성공/실패))) 0x80 Storage[3]-as-address
BFRETURN

따라서 호출 후 반환 데이터를 버퍼 0x80 - 0x80+RETURNDATASIZE에 복사하고 호출이 성공하면 해당 버퍼로 RETURN합니다.

DELEGATECALL 실패

여기 0xC0에 도착하면 우리가 호출한 계약이 되돌려졌다는 의미입니다. 우리는 해당 계약의 프록시일 뿐이므로 동일한 데이터를 반환하고 되돌리고 싶습니다.

오프셋연산 부호스택
C0JUMPDEST(((호출 성공/실패))) RETURNDATASIZE (((호출 성공/실패))) 0x80 Storage[3]-as-address
C1DUP2RETURNDATASIZE (((호출 성공/실패))) RETURNDATASIZE (((호출 성공/실패))) 0x80 Storage[3]-as-address
C2DUP50x80 RETURNDATASIZE (((호출 성공/실패))) RETURNDATASIZE (((호출 성공/실패))) 0x80 Storage[3]-as-address
C3REVERT

따라서 이전에 RETURN에 사용했던 것과 동일한 버퍼로 REVERT합니다: 0x80 - 0x80+RETURNDATASIZE

프록시 호출 순서도

ABI 호출

호출 데이터 크기가 4바이트 이상이면 유효한 ABI 호출일 수 있습니다.

오프셋연산 부호스택
DPUSH1 0x000x00
FCALLDATALOAD(((호출 데이터의 첫 단어(256비트)))
10PUSH1 0xe00xE0 (((호출 데이터의 첫 단어(256비트)))
12SHR(((호출 데이터의 처음 32비트(4바이트)))

Etherscan은 1C가 알려지지 않은 연산 부호라고 알려주는데, Etherscan이 이 기능을 작성한 후에 추가되었고 (opens in a new tab) 업데이트되지 않았기 때문입니다. 최신 연산 부호 표 (opens in a new tab)를 보면 이것이 오른쪽 시프트임을 알 수 있습니다.

오프셋연산 부호스택
13DUP1(((호출 데이터의 처음 32비트(4바이트))) (((호출 데이터의 처음 32비트(4바이트)))
14PUSH4 0x3cd8045e0x3CD8045E (((호출 데이터의 처음 32비트(4바이트))) (((호출 데이터의 처음 32비트(4바이트)))
19GT0x3CD8045E>호출 데이터의 처음 32비트 (((호출 데이터의 처음 32비트(4바이트)))
1APUSH2 0x00430x43 0x3CD8045E>호출 데이터의 처음 32비트 (((호출 데이터의 처음 32비트(4바이트)))
1DJUMPI(((호출 데이터의 처음 32비트(4바이트)))

이처럼 메서드 서명 일치 테스트를 둘로 나누면 평균적으로 테스트의 절반을 절약할 수 있습니다. 이를 바로 뒤따르는 코드와 0x43의 코드는 동일한 패턴을 따릅니다: 호출 데이터의 처음 32비트를 DUP1하고, PUSH4 (((메서드 서명))을 실행하고, EQ를 실행하여 동등성을 확인한 다음, 메서드 서명이 일치하면 JUMPI합니다. 다음은 메서드 서명, 해당 주소, 그리고 알려진 경우 해당 메서드 정의 (opens in a new tab)입니다.

메서드메서드 서명점프할 오프셋
splitter() (opens in a new tab)0x3cd8045e0x0103
???0x81e580d30x0138
currentWindow() (opens in a new tab)0xba0bafb40x0158
???0x1f1358230x00C4
merkleRoot() (opens in a new tab)0x2eb4a7ab0x00ED

일치하는 항목이 없으면 코드는 프록시인 계약에 일치하는 항목이 있기를 바라며 0x7C의 프록시 핸들러로 점프합니다.

ABI 호출 순서도

splitter()

오프셋연산 부호스택
103JUMPDEST
104CALLVALUECALLVALUE
105DUP1CALLVALUE CALLVALUE
106ISZEROCALLVALUE==0 CALLVALUE
107PUSH2 0x010f0x010F CALLVALUE==0 CALLVALUE
10AJUMPICALLVALUE
10BPUSH1 0x000x00 CALLVALUE
10DDUP10x00 0x00 CALLVALUE
10EREVERT

이 함수가 가장 먼저 하는 일은 호출이 ETH를 보내지 않았는지 확인하는 것입니다. 이 함수는 payable이 아닙니다 (opens in a new tab). 누군가 우리에게 ETH를 보냈다면 그것은 실수임에 틀림없으며, 그들이 다시 돌려받을 수 없는 곳에 ETH가 있는 것을 피하기 위해 REVERT하고 싶습니다.

오프셋연산 부호스택
10FJUMPDEST
110POP
111PUSH1 0x030x03
113SLOAD(((Storage[3], 즉 우리가 프록시인 계약)))
114PUSH1 0x400x40 (((Storage[3], 즉 우리가 프록시인 계약)))
116MLOAD0x80 (((Storage[3], 즉 우리가 프록시인 계약)))
117PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xFF...FF 0x80 (((Storage[3], 즉 우리가 프록시인 계약)))
12CSWAP10x80 0xFF...FF (((Storage[3], 즉 우리가 프록시인 계약)))
12DSWAP2(((Storage[3], 즉 우리가 프록시인 계약))) 0xFF...FF 0x80
12EANDProxyAddr 0x80
12FDUP20x80 ProxyAddr 0x80
130MSTORE0x80

그리고 0x80은 이제 프록시 주소를 포함합니다.

오프셋연산 부호스택
131PUSH1 0x200x20 0x80
133ADD0xA0
134PUSH2 0x00e40xE4 0xA0
137JUMP0xA0

E4 코드

이 줄은 처음 보지만 다른 메서드와 공유됩니다(아래 참조). 따라서 스택의 값을 X라고 부르고, splitter()에서 이 X의 값이 0xA0이라는 것을 기억하십시오.

오프셋연산 부호스택
E4JUMPDESTX
E5PUSH1 0x400x40 X
E7MLOAD0x80 X
E8DUP10x80 0x80 X
E9SWAP2X 0x80 0x80
EASUBX-0x80 0x80
EBSWAP10x80 X-0x80
ECRETURN

따라서 이 코드는 스택(X)에서 메모리 포인터를 수신하고 계약이 0x80 - X인 버퍼로 RETURN하도록 합니다.

splitter()의 경우, 이는 우리가 프록시인 주소를 반환합니다. RETURN은 0x80-0x9F의 버퍼를 반환하며, 여기에 이 데이터를 썼습니다(위 오프셋 0x130).

currentWindow()

오프셋 0x158-0x163의 코드는 splitter()의 0x103-0x10E에서 본 것과 동일하므로(JUMPI 대상 제외), currentWindow()payable이 아님을 알 수 있습니다.

오프셋연산 부호스택
164JUMPDEST
165POP
166PUSH2 0x00da0xDA
169PUSH1 0x010x01 0xDA
16BSLOADStorage[1] 0xDA
16CDUP20xDA Storage[1] 0xDA
16DJUMPStorage[1] 0xDA

DA 코드

이 코드는 다른 메서드와도 공유됩니다. 따라서 스택의 값을 Y라고 부르고, currentWindow()에서 이 Y의 값이 Storage[1]이라는 것을 기억하십시오.

오프셋연산 부호스택
DAJUMPDESTY 0xDA
DBPUSH1 0x400x40 Y 0xDA
DDMLOAD0x80 Y 0xDA
DESWAP1Y 0x80 0xDA
DFDUP20x80 Y 0x80 0xDA
E0MSTORE0x80 0xDA

Y를 0x80-0x9F에 씁니다.

오프셋연산 부호스택
E1PUSH1 0x200x20 0x80 0xDA
E3ADD0xA0 0xDA

그리고 나머지는 이미 위에서 설명했습니다. 따라서 0xDA로 점프하면 스택 맨 위(Y)를 0x80-0x9F에 쓰고 해당 값을 반환합니다. currentWindow()의 경우 Storage[1]을 반환합니다.

merkleRoot()

오프셋 0xED-0xF8의 코드는 splitter()의 0x103-0x10E에서 본 것과 동일하므로(JUMPI 대상 제외), merkleRoot()payable이 아님을 알 수 있습니다.

오프셋연산 부호스택
F9JUMPDEST
FAPOP
FBPUSH2 0x00da0xDA
FEPUSH1 0x000x00 0xDA
100SLOADStorage[0] 0xDA
101DUP20xDA Storage[0] 0xDA
102JUMPStorage[0] 0xDA

점프 후에 일어나는 일은 이미 알아냈습니다. 따라서 merkleRoot()는 Storage[0]을 반환합니다.

0x81e580d3

오프셋 0x138-0x143의 코드는 splitter()의 0x103-0x10E에서 본 것과 동일하므로(JUMPI 대상 제외), 이 함수도 payable이 아님을 알 수 있습니다.

오프셋연산 부호스택
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

이 함수는 최소 32바이트(한 단어)의 호출 데이터를 사용하는 것 같습니다.

오프셋연산 부호스택
19DDUP10x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19EDUP20x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19FREVERT

호출 데이터를 얻지 못하면 트랜잭션은 반환 데이터 없이 되돌려집니다.

함수가 필요한 호출 데이터를 얻을 경우 어떻게 되는지 봅시다.

오프셋연산 부호스택
1A0JUMPDEST0x00 0x04 CALLDATASIZE 0x0153 0xDA
1A1POP0x04 CALLDATASIZE 0x0153 0xDA
1A2CALLDATALOADcalldataload(4) CALLDATASIZE 0x0153 0xDA

calldataload(4)는 메서드 서명 의 호출 데이터의 첫 단어입니다.

오프셋연산 부호스택
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

첫 번째 단어가 Storage[4]보다 작지 않으면 함수가 실패합니다. 반환된 값 없이 되돌립니다:

오프셋연산 부호스택
17APUSH1 0x000x00 ...
17CDUP10x00 0x00 ...
17DREVERT

calldataload(4)가 Storage[4]보다 작으면 이 코드를 얻습니다.

오프셋연산 부호스택
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

그리고 메모리 위치 0x00-0x1F는 이제 데이터 0x04를 포함합니다(0x00-0x1E는 모두 0이고 0x1F는 4입니다).

오프셋연산 부호스택
184PUSH1 0x200x20 calldataload(4) 0x00 calldataload(4) 0xDA
186SWAP1calldataload(4) 0x20 0x00 calldataload(4) 0xDA
187SWAP20x00 0x20 calldataload(4) calldataload(4) 0xDA
188SHA3(((0x00-0x1F의 SHA3))) calldataload(4) calldataload(4) 0xDA
189ADD(((0x00-0x1F의 SHA3)))+calldataload(4) calldataload(4) 0xDA
18ASLOADStorage[(((0x00-0x1F의 SHA3))) + calldataload(4)] calldataload(4) 0xDA

따라서 저장 공간에는 0x000...0004의 SHA3에서 시작하여 모든 합법적인 호출 데이터 값(Storage[4] 아래 값)에 대한 항목이 있는 조회 테이블이 있습니다.

오프셋연산 부호스택
18BSWAP1calldataload(4) Storage[(((0x00-0x1F의 SHA3))) + calldataload(4)] 0xDA
18CPOPStorage[(((0x00-0x1F의 SHA3))) + calldataload(4)] 0xDA
18DDUP20xDA Storage[(((0x00-0x1F의 SHA3))) + calldataload(4)] 0xDA
18EJUMPStorage[(((0x00-0x1F의 SHA3))) + calldataload(4)] 0xDA

우리는 이미 오프셋 0xDA의 코드가 무엇을 하는지 알고 있으며, 스택 맨 위 값을 호출자에게 반환합니다. 따라서 이 함수는 조회 테이블의 값을 호출자에게 반환합니다.

0x1f135823

오프셋 0xC4-0xCF의 코드는 splitter()의 0x103-0x10E에서 본 것과 동일하므로(JUMPI 대상 제외), 이 함수도 payable이 아님을 알 수 있습니다.

오프셋연산 부호스택
D0JUMPDEST
D1POP
D2PUSH2 0x00da0xDA
D5PUSH1 0x060x06 0xDA
D7SLOADValue* 0xDA
D8DUP20xDA Value* 0xDA
D9JUMPValue* 0xDA

우리는 이미 오프셋 0xDA의 코드가 무엇을 하는지 알고 있으며, 스택 맨 위 값을 호출자에게 반환합니다. 따라서 이 함수는 Value*를 반환합니다.

메서드 요약

이 시점에서 계약을 이해하고 있다고 느끼십니까? 그렇지 않습니다. 지금까지 다음과 같은 메서드를 살펴보았습니다.

메서드의미
전송호출에서 제공된 값을 수락하고 Value*를 해당 금액만큼 증가시킵니다.
splitter()프록시 주소인 Storage[3]을 반환합니다.
currentWindow()Storage[1] 반환
merkleRoot()Storage[0] 반환
0x81e580d3매개 변수가 Storage[4]보다 작은 경우 조회 테이블에서 값을 반환합니다.
0x1f135823Storage[6] 반환(일명 값*

그러나 다른 기능은 Storage[3]의 계약에서 제공된다는 것을 알고 있습니다. 아마도 그 계약이 무엇인지 알면 단서를 얻을 수 있을 것입니다. 고맙게도 이것은 블록체인이며 적어도 이론적으로는 모든 것이 알려져 있습니다. Storage[3]을 설정하는 메서드를 보지 못했으므로 생성자가 설정했을 것입니다.

생성자

계약을 살펴보면 (opens in a new tab) 해당 계약을 생성한 트랜잭션도 볼 수 있습니다.

생성 트랜잭션 클릭

해당 트랜잭션을 클릭한 다음 상태 탭을 클릭하면 매개 변수의 초기 값을 볼 수 있습니다. 특히 Storage[3]에 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761 (opens in a new tab)이 포함되어 있음을 알 수 있습니다. 해당 계약에는 누락된 기능이 포함되어 있어야 합니다. 조사 중인 계약에 사용한 것과 동일한 도구를 사용하여 이해할 수 있습니다.

프록시 계약

위의 원래 계약에 사용한 것과 동일한 기술을 사용하여 계약이 다음과 같은 경우 되돌려지는 것을 볼 수 있습니다.

  • 호출에 연결된 ETH가 있습니다(0x05-0x0F).
  • 호출 데이터 크기가 4보다 작습니다(0x10-0x19 및 0xBE-0xC2).

그리고 지원하는 방법은 다음과 같습니다.

아래 네 가지 방법은 결코 도달할 수 없으므로 무시할 수 있습니다. 해당 서명은 원래 계약이 자체적으로 처리하도록 되어 있으므로(위의 서명을 클릭하면 자세한 내용을 볼 수 있음), 재정의된 메서드 (opens in a new tab)임에 틀림없습니다.

남은 메서드 중 하나는 claim(<params>)이고 다른 하나는 isClaimed(<params>)이므로 에어드랍 계약으로 보입니다. 나머지 연산 부호를 하나씩 살펴보는 대신, 이 계약의 세 함수에 대해 사용 가능한 결과를 생성하는 디컴파일러를 사용해 볼 수 있습니다 (opens in a new tab). 다른 것들을 리버스 엔지니어링하는 것은 독자의 연습 과제로 남겨둡니다.

scaleAmountByPercentage

이것이 이 함수에 대해 디컴파일러가 제공하는 것입니다.

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

첫 번째 require는 호출 데이터가 함수 서명의 4바이트 외에 두 매개 변수에 충분한 최소 64바이트를 가지고 있는지 테스트합니다. 그렇지 않다면 분명히 뭔가 잘못된 것입니다.

if 문은 _param1이 0이 아니고 _param1 * _param2가 음수가 아님을 확인하는 것으로 보입니다. 아마도 랩 어라운드(wrap around) 사례를 방지하기 위함일 것입니다.

마지막으로 함수는 확장된 값을 반환합니다.

claim

디컴파일러가 생성하는 코드는 복잡하며, 모든 것이 우리와 관련이 있는 것은 아닙니다. 유용한 정보를 제공한다고 생각되는 줄에 집중하기 위해 일부를 건너뛸 것입니다.

1def unknown2e7ba6ef(uint256 _param1, uint256 _param2, uint256 _param3, array _param4) payable:
2 ...
3 require _param2 == addr(_param2)
4 ...
5 if currentWindow <= _param1:
6 revert with 0, '미래 창에 대한 클레임 불가'

여기서 두 가지 중요한 것을 알 수 있습니다.

  • _param2uint256으로 선언되었지만 실제로는 주소입니다.
  • _param1은 클레임되는 창이며, currentWindow 또는 그 이전이어야 합니다.
1 ...
2 if stor5[_claimWindow][addr(_claimFor)]:
3 revert with 0, '계정이 이미 해당 창을 클레임했습니다'

이제 Storage[5]가 창과 주소의 배열이며, 해당 주소가 해당 창에 대한 보상을 클레임했는지 여부를 알 수 있습니다.

1 ...
2 idx = 0
3 s = 0
4 while idx < _param4.length:
5 ...
6 if 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 continue
11 ...
12 s = sha3(mem[_66 + 32 len mem[_66]])
13 continue
14 if unknown2eb4a7ab != s:
15 revert with 0, '잘못된 증명'
모두 보기

우리는 unknown2eb4a7ab가 실제로는 merkleRoot() 함수라는 것을 알고 있으므로, 이 코드는 머클 증명 (opens in a new tab)을 검증하는 것으로 보입니다. 이는 _param4가 머클 증명임을 의미합니다.

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

이것이 계약이 자체 ETH를 다른 주소(계약 또는 외부 소유)로 전송하는 방법입니다. 전송할 금액인 값으로 호출합니다. 따라서 이것은 ETH의 에어드랍으로 보입니다.

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

아래 두 줄은 Storage[2]도 우리가 호출하는 계약임을 알려줍니다. 생성자 트랜잭션을 살펴보면 (opens in a new tab) 이 계약이 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 (opens in a new tab)인 래핑된 이더 계약이며 소스 코드가 Etherscan에 업로드된 (opens in a new tab) 것을 알 수 있습니다.

따라서 계약이 ETH를 _param2로 보내려고 시도하는 것으로 보입니다. 할 수 있다면 좋습니다. 그렇지 않으면 WETH (opens in a new tab)를 보내려고 시도합니다. _param2가 외부 소유 계정(EOA)인 경우 항상 ETH를 수신할 수 있지만 계약은 ETH 수신을 거부할 수 있습니다. 그러나 WETH는 ERC-20이며 계약은 이를 수락하는 것을 거부할 수 없습니다.

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

함수 끝에서 로그 항목이 생성되는 것을 볼 수 있습니다. 생성된 로그 항목을 보고 (opens in a new tab) 0xdbd5...로 시작하는 주제를 필터링합니다. 이러한 항목을 생성한 트랜잭션 중 하나를 클릭하면 (opens in a new tab) 실제로 클레임처럼 보이는 것을 알 수 있습니다. 계정은 리버스 엔지니어링 중인 계약에 메시지를 보냈고, 그 대가로 ETH를 받았습니다.

클레임 트랜잭션

1e7df9d3

이 함수는 위의 claim과 매우 유사합니다. 또한 머클 증명을 확인하고, ETH를 첫 번째로 전송하려고 시도하며, 동일한 유형의 로그 항목을 생성합니다.

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 continue
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, '잘못된 증명'
17 ...
18 call addr(_param1) with:
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() with:
25 value s wei
26 gas gas_remaining wei
27 ...
28 log 0xdbd5389f: addr(_param1), s, bool(ext_call.success)
모두 보기

주요 차이점은 첫 번째 매개 변수인 인출할 창이 없다는 것입니다. 대신 클레임할 수 있는 모든 창을 순회하는 루프가 있습니다.

1 idx = 0
2 s = 0
3 while idx < currentWindow:
4 ...
5 if stor5[mem[0]]:
6 if idx == -1:
7 revert with 0, 17
8 idx = idx + 1
9 s = s
10 continue
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 continue
모두 보기

따라서 모든 창을 클레임하는 claim 변형으로 보입니다.

결론

이제 연산 부호 또는 (작동하는 경우) 디컴파일러를 사용하여 소스 코드를 사용할 수 없는 계약을 이해하는 방법을 알아야 합니다. 이 글의 길이에서 알 수 있듯이, 계약을 리버스 엔지니어링하는 것은 사소한 일이 아니지만, 보안이 필수적인 시스템에서는 계약이 약속대로 작동하는지 확인할 수 있는 중요한 기술입니다.

여기서 제 작업에 대한 자세한 내용을 확인하세요 (opens in a new tab).

페이지 마지막 업데이트됨: 2025년 8월 22일

이 튜토리얼이 도움이 되셨나요?