[Ethereum] Assembly 깨부수기

0xDave·2022년 12월 23일
0

Ethereum

목록 보기
78/112

Code4rena 콘테스트에 참여하거나 리포트를 보다보면 assembly를 종종 볼 수 있다. 볼 때마다 아직도 정확하게 돌아가는 메커니즘을 이해하지 못해서 제대로 배워보려고 한다. 저번에 All about Assembly 글을 쓰면서 기초는 배웠지만 아직 그 이상을 이해하지 못하는 수준이다. 그러던 중 좋은 트윗을 발견했다. 해당 글을 따라가면서 학습과정을 기록하고자 한다.


⛓ Base contract


contract AngleExplainsBase {
    uint private secretNumber;
    mapping(address =>  uint) public guesses;
    
    bytes32 public secretWord;
    // obviously this doesn't make sense
    // but it will be fun to write it in assembly :D
    function getSecretNumber() external view returns(uint) {
        return secretNumber;
    }
    // this should only be set by an admin
    // no access control because we want to keep it simple in assembly
    function setSecretNumber(uint number) external {
        secretNumber = number;
    }
    // a user can add a guess
    function addGuess(uint _guess) external {
        guesses[msg.sender] = _guess;
    }
    // yes i know... it doesn't make sense because you can change guesses for any user
    // it's just to teach you how to parse arrays in assembly
    function addMultipleGuesses(address[] memory _users, uint[] memory _guesses) external {
        for (uint i = 0; i < _users.length; i++) {
            guesses[_users[i]] = _guesses[i];
        }
    }
    // this is useless since the `secretWord` is not used anywhere
    // but this will teach us how to hash a string in assembly. Really cool! :)
    function hashSecretWord(string memory _str) external {
        secretWord = keccak256(abi.encodePacked(_str));
    }
}

위 컨트랙트는 간단한 추첨 컨트랙트다. access control도 없고 secret number도 그냥 보여주는 허술한 컨트랙트다. 일부러 간단하게 만든 이유는 나중에 assembly로 바꿀 때 이해하기 쉽도록 만들었다고 한다.


🤖 EVM opcodes


assembly 코드에는 다양한 opcode가 있다. evm codes에 보면 잘 정리되어 있으니 참고하자.


getSecretNumber


    uint private secretNumber;

	//..
    
	function getSecretNumber() external view returns(uint) {
        return secretNumber;
    }

getSecretNumber는 secretNumber를 리턴하는 간단한 함수다. memory에 저장된 secretNumber를 가져오려면 SLOAD를 사용하면 된다. EVM은 memory에 저장된 데이터만 리턴할 수 있다. 따라서 memory에 저장된 secretNumber를 가져오기 전에 memory에 해당 값을 저장해줘야 한다. 이러한 역할을 하는 opcodes는 MSTORE다. 이제 위 함수를 assembly로 바꿔보자.


function getSecretNumber() external view returns(uint) {
        assembly {
          
            // 스토리지의 0번째 slot에 있는 secretNumber 값을 가져와서 변수에 할당한다.
            // Yul에서는 `.slot`을 통해 변수 자체의 slot을 가져올 수 있다.
            // `sload(secretNumber.slot)`과 같이 작성할 수 있다.
            let _secretNumber := sload(0)
          
            // "free memory pointer" -> 우리가 사용할 수 있는 메모리 주소
            // MLOAD를 이용해 해당 메모리의 주소를 가져온다.
            // 0x40 (64)에 저장된 값을 가져온다.
            // 0x40은 고정적으로 free memory의 주소가 저장된 위치다.
            let ptr := mload(0x40)
          
            // MSTORE를 이용해 해당 위치에 secretNumber 값을 저장한다.
            // mstore(저장할 주소 위치, 저장할 값)
            mstore(ptr, _secretNumber)
          
            // 마지막으로 값을 리턴한다.
            // return(저장된 위치, 해당 인자의 크기)
            // 값은 항상 32바이트로 저장되기 때문에 0x20이 온다.
            return(ptr, 0x20)
        }
    }

좀 더 간결하게 아래처럼 작성할 수 있다.


// free memory pointer를 사용하는 대신 메모리의 0번째 슬롯에 값을 저장할 수 있다.
// 처음 2개의 슬롯은 "scratch space"로 사용되기 때문이다.
// "scratch space"에는 리턴값과 같은 임시값(temporary values)을 저장할 수 있다.
assembly {
	let _secretNumber := sload(0)
	mstore(0, _secretNumber)
	return(0, 0x20)
}

setSecretNumber


이제 setSecretNumber를 assembly로 바꿔보자.

    function setSecretNumber(uint number) external {
        secretNumber = number;
    }

setSecretNumber는 의외로 간단하다. 해당 변수가 저장된 slot을 불러오고, 해당 슬롯에 인자로 받은 값(_number)을 저장하면 된다.

function setSecretNumber(uint _number) external {
        assembly {
            // We get the slot number for `secretNumber`
            let slot := secretNumber.slot
            
            // We use SSTORE to store the new value
            sstore(slot, _number)
        }
    }

addGuess

    mapping(address =>  uint) public guesses;

	//..

    function addGuess(uint _guess) external {
        guesses[msg.sender] = _guess;
    }

addGuess는 mapping을 이용한다. mapping은 key값과 슬롯 넘버를 합친다음 keccak256을 통해 해싱하는 방식으로 동작한다. 현재 guesses는 slot 1에 저장돼있다.(0 번째는 secretNumber가 저장돼있으므로 그 다음 순서에 저장된다.) 따라서 솔리디티에서는 아래와 같은 방법으로 슬롯을 가져올 수 있다.

keccak256(abi.encode(msg.sender, 1))

Yul에서 해싱할 때는 우선 메모리에 값을 저장해야 된다. keccak256은 메모리에만 접근할 수 있기 때문이다. 슬롯 넘버를 가져오는 것은 다음과 같은 순서로 진행된다.

  1. msg.sender 주소를 가져온다.
  2. mapping의 슬롯 넘버를 가져온다.
  3. memory에 두 값을 저장한다. (순서대로 저장)
  4. 해싱한다.

addGuess를 아래처럼 assembly로 바꿀 수 있다.

function addGuess(uint _guess) external {
        assembly {
            // 우리가 저장할 슬롯 위치를 가져온다.(우리가 사용할 수 있는 메모리 주소)
            let ptr := mload(0x40)

          	// caller()를 통해 msg.sender 주소를 불러온다.
            // msg.sender 주소를 ptr에 저장한다.
            mstore(ptr, caller())

            // 그 다음, guesses의 슬롯 넘버를 저장한다.
            mstore(add(ptr, 0x20), guesses.slot)

            // 위 2개의 MSTORE는 abi.encode(msg.sender, 1)와 같다.

            // msg.sender 주소와 guesses.slot 값을 해싱한다.
            // 현재 2개의 값은 ptr에 저장되어 있고 총 2개의 슬롯을 사용한다.(2x 32bytes -> 0x40 = 64)
            //keccak256의 두 번째 인자는 해싱할 데이터의 크기를 의미한다.
            let slot := keccak256(ptr, 0x40)

            // 이제 슬롯에 인자로 받은 값을 저장한다.
            sstore(slot, _guess)
        }
    }

add(ptr, 0x20)는 ptr로 부터 32바이트만큼 떨어진 위치를 나타낸다. 즉, ptr의 다음 메모리 슬롯을 의미한다. 솔리디티로 바꾸면 아래처럼 나타낼 수 있다.

ptr = ptr + 32

hashSecretWord

    function hashSecretWord(string memory _str) external {
        secretWord = keccak256(abi.encodePacked(_str));
    }

값을 받아서 해싱하는 간단한 함수다. 더 진행하기 전에 간단히 알아두어야 할 것이 있다.

Non-value types

array, mapping, bytes, string은 EVM에서 다른 데이터 타입과 다르게 2개의 파트로 나뉘어서 저장된다. 첫 번째는 데이터의 길이 값이 저장되고 두 번째는 해당 데이터 값이 저장된다. 예를 들어 angel이라는 값을 저장한다고 해보자. angel의 길이 값인 5와 angel이 같이 저장된다. 32바이트로 바뀌어서 저장되기 때문에 아래처럼 표현할 수 있다.

0000000000000000000000000000000000000000000000000000000000000005616e676c65000000000000000000000000000000000000000000000000000000

616e676c65(=angel) 앞에 길이 값인 5가 있는 것을 알 수 있다. 이제 assembly로 바뀐 함수를 살펴보자.


// computes the keccak256 hash of a string and stores it in a state variable
function hashSecretWord1(string memory _str) external view returns(bytes32) {
    assembly {
      
        // _str은 string을 가리키는 포인터다.
        // 즉, string이 시작하는 메모리 주소를 가리킨다.
        // _str에 string의 길이에 대한 정보가 있다.
        // _str에 32를 더하면 string 정보가 있다.
      
        // string의 크기값을 가져온다.
        let strSize := mload(_str)
        //_str에 32를 더해서, string 정보(값)을 가져온다.
        let strAddr := add(_str, 32)
        // string 주소와 크기를 인자로 넣어서 해싱한다.
        let hash := keccak256(strAddr, strSize)
        // 해싱 결과는 메모리의 0 번째 슬롯(temporary storage / scratch space)에 저장된다.
        // 더 싸고 빠르기 때문에 free memory pointer를 사용할 필요가 없다.
        mstore(0, hash)
        // 0 번째 슬롯에 저장된 값을 리턴한다.(32는 데이터의 크기를 의미)
        return (0, 32)
    }
}

stablecoin_str로 넣어줬을 때 위와 같이 나타낼 수 있다. 0xc0은 stablecoin 다음 슬롯을 가리키는 free memory pointer다. 그런데 어떻게 맨 처음 위치(memory)에 값을 할당할 수 있었을까? 이전에 데이터가 저장되는 경우는 없을까? 답은 string memory _str에 있다. 파라미터에 memory를 사용할 때 EVM은 해당 파라미터가 사용할 공간을 미리 준비한다. 만약 memory 대신 calldata를 사용한다면 이러한 방식을 사용하지 않기 때문에 가스비를 아낄 수 있다. 똑같은 함수를 calldata를 사용해서 나타내면 다음과 같다.


// hash를 리턴하는 대신, secretWord 변수를 스토리지에 할당한다.
function hashSecretWord2(string calldata) external {
    assembly {
        // calldata는 함수가 호출될 때 컨트랙트에 들어오는 모든 데이터를 말한다.
        // 첫 4 바이트는 함수를 나타내고, 나머지는 파라미터를 나타낸다.
        // CALLDATALOAD를 사용하면 calldata로 부터 32바이트의 정보를 가져올 수 있다.
      
        // calldataload(4)를 사용하면 함수를 나타내는 바이트를 스킵할 수 있다. 따라서 우리는 첫 번째 파라미터에 대한 정보를 가져온다.
        // non-value types (array, mapping, bytes, string)을 사용할 때, 첫 번째 파라미터는 파라미터가 시작하는 offset이 된다.
        // offset을 통해 파라미터의 길이 값과 value 값을 찾을 수 있다.
      
        // calldataload(4) -> string이 시작하는 offset을 가져온다.
        // signature bytes를 가져오기 위해 offset에 4를 더한다.
        let strOffset := add(4, calldataload(4))
      
        // offset에 calldataload()를 다시 해서 string 길이 값을 가져온다. (offset에 value 값이 저장되어 있음)
        let strSize := calldataload(strOffset)
      
        // free memory pointer를 가져오고
        let ptr := mload(0x40)
      
        // CALLDATACOPY를 이용해 free memory에 string 값을 복사한다.
        // CALLDATACOPY(데이터를 붙여넣을 위치, 복사할 데이터의 위치, 데이터의 크기)
        // string은 다음 메모리 슬롯에서 시작하므로 0x20을 더해준다.
        calldatacopy(ptr, add(strOffset, 0x20), strSize)
      
        // 이후 string을 해싱한다. (현재 string은 ptr에 저장되어 있음)
        let hash := keccak256(ptr, strSize)
      
        // 해싱 값을 storage에 저장한다.
        sstore(secretWord.slot, hash)
    }
}

offset은 정보가 시작하는 위치를 말한다.


calldata에 대해 좀 더 알아보자

function myToken(string memory name, uint randomValue, address[] memory _addresses)

위 함수에 다음과 같은 인자를 넣어줬다고 해보자.

“angle”, 7, [0x31429d1856aD1377A8A0079410B297e1a9e214c2,0x1a7e4e63778B4f12a199C062f3eFdD288afCBce8]

해당 calldata를 살펴보면 다음과 같다. 첫 번째 4바이트는 함수 시그니쳐를 나타낸다.

050eed26
0000000000000000000000000000000000000000000000000000000000000060
0000000000000000000000000000000000000000000000000000000000000007
00000000000000000000000000000000000000000000000000000000000000a0
0000000000000000000000000000000000000000000000000000000000000005
616e676c65000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000002
00000000000000000000000031429d1856ad1377a8a0079410b297e1a9e214c2
0000000000000000000000001a7e4e63778b4f12a199c062f3efdd288afcbce8

업로드중..

0x04를 보면 60(0x60)이 있는데, 이는 string의 길이값이 저장되어있는 슬롯의 위치를 나타낸다. 즉 0x60에는 angel의 글자 수인 5가 저장되어 있고, 그 다음에는 angel을 의미하는 616e676c65이 있다. 신기한 것은 파라미터의 순서 상 angel이 먼저지만 슬롯에 저장되는 순서는 uint인 7이 먼저다.

표의 첫 번째 열은 함수 시그니쳐를 포함한 offset을 나타낸다. 따라서 파라미터의 정보를 나타내는 offset은 여기서 4를 더해야 한다.


addMultipleGuesses


이제 거의 다 왔다. 마지막 함수를 살펴보자.

    function addMultipleGuesses(address[] memory _users, uint[] memory _guesses) external {
        for (uint i = 0; i < _users.length; i++) {
            guesses[_users[i]] = _guesses[i];
        }
    }

function addMultipleGuesses(address[] memory _users, uint[] memory _guesses) external {
        assembly {
            // `_users` array를 메모리로부터 가져오면 array의 크기 값을 가져올 수 있다. 
            // 크기 값에 32 bytes 이후에는 array의 value 값이 있다.
            let usersSize := mload(_users)
            // `_guesses`도 마찬가지
            let guessesSize := mload(_guesses)
            
            // 두 array가 같은 크기인지 비교
            // eq는 값이 같으면 1을 리턴하고, 다르면 0을 리턴한다.
            // iszero는 값이 0이면 1을 리턴하고, 0이 아니면 0을 리턴한다.
            if iszero(eq(usersSize, guessesSize)) { revert(0, 0) }
            
            // 배열의 item마다 for-loop 적용
            // lt(a,b)는 a < b이면 1을 리턴, 아니면 0을 리턴 
            for { let i := 0 } lt(i, usersSize) { i := add(i, 1) } {
              
                // array에서 index를 가져오면 32 (0x20)를 곱하고 `_users`에 더해준다.
                // 항상 i에 1을 먼저 더해줘야 하는데, 우리가 받아오는 i는 크기를 나타내는 값이기 때문에 1을 더해줘서(결국 32바이트를 더해주는 것) 그 다음 바이트 값인 value 값을 가져와야 하기 때문이다. 
                let userAddress := mload(add(_users, mul(0x20, add(i, 1))))
                let userBalance := mload(add(_guesses, mul(0x20, add(i, 1))))
              
                // memory slot 0을 임시 저장 공간으로 사용
                mstore(0, userAddress)
                // `guesses`가 담겨있는 slot 번호를 가져오고
                mstore(0x20, guesses.slot)
                // 저장할 storage slot 번호 계산
                let slot := keccak256(0, 0x40)
                // 해당 storage slot에 값 저장
                sstore(slot, userBalance)
            }
        }
    }

아직 익숙하진 않아서 assembly 코드들을 많이 봐야할 것 같다. 그래도 조금씩 나아지고 있는 느낌이 든다!

profile
Just BUIDL :)

0개의 댓글