문제 링크

문제 코드

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Delegate {
    address public owner;

    constructor(address _owner) {
        owner = _owner;
    }

    function pwn() public {
        owner = msg.sender;
    }
}

contract Delegation {
    address public owner;
    Delegate delegate;

    constructor(address _delegateAddress) {
        delegate = Delegate(_delegateAddress);
        owner = msg.sender;
    }

    fallback() external {
        (bool result,) = address(delegate).delegatecall(msg.data);
        if (result) {
            this;
        }
    }
}

Delegation 컨트랙트의 owner 를 유저의 address 로 교체하면 되는 문제이다.

두 개의 컨트랙트중 사용자에게 주어지는것은 Delegation 이고 내부에서 owner 를 교체하는 부분은 존재하지 않아 불가능해 보인다.

그러나 delegatecall 의 원리를 잘 알고있다면 이를 이용해서 owner 를 교체하는 것이 가능하다.

call vs staticcall vs delegatecall

delegatecall 을 설명하기 앞서 본래 solidity 에서 함수를 호출하기 위한 opcode 는 세 가지(call, staticcall, delegatecall)가 있다.

모두 다른 컨트랙트의 함수를 호출하거나 또는 단순 ether 를 전송하기 위해 쓰이나 차이점이 존재하므로 정리하도록 한다.

call

그림에서 contract A 는 contract B 의 모 함수를 호출하여 숫자에 1,2를 더하고 있다. 뒤에 나올 delegate call 과의 비교를 잠깐 해보자면 더한 숫자는 A 에 적용되지 않고 B에 적용되고 있다.

호출 형태

(bool success, bytes memory data) = address.call{value: number, gas: number}(<payload>);

value 값을 채워 넣으면 그만큼의 wei 를 전송하고 gas 는 forward 할 gas 량을 지정한다 (지정하지 않아도 되는데 이 경우 모든 gas 를 넘긴다). payload 는 호출하고자 하는 함수와 인자를 abi encode 된 값이다.

리턴 값

두 가지 변수를 리턴한다.

  • success: 호출된 컨트랙트에서 에러가 발생하거나 가스가 부족하여 종료될 시 false, 나머지 정상종료는 true
  • 대상 함수의 리턴을 abi encode 하여 data 를 리턴한다.

tips
기본적으로 다른 컨트랙트 함수 호출을 위해 low-level call은 잘 이용하지 않는다. 그 이유는 error 가 상위 함수로 자동적으로 bubble-up 되지 않기 때문. 즉 call 로 호출한 함수가 에러를 발생시키면 리턴된 false 값과 data 를 확인하여 수동적으로 대응해야한다는 문제점이 있다.

특징

  1. call 로 호출한 함수가 storage 값을 변경하는 경우 대상 컨트랙트에 적용한다. 위의 예시 그림에서는 B 에서 실행된 num += 1, num += 2 라는 코드는 B 의 num1 과 num2 에 적용된다.
  2. msg.sender, msg.value 값이 변경된다. B 입장에서 msg.sender 는 A 컨트랙트의 address 가 된다.

staticcall

기본적으로 call 과 같지만 다른점은 storage 변형을 허락하지 않는다..!
따라서 storage를 읽어서 데이터를 리턴하거나 storage 값과 관련없이 동작하는 함수를 호출하는데 쓰일 수 있다.

delegatecall

호출 형태

(bool success, bytes memory data) = address.delegatecall{gas: number}(<payload>);

call 처럼 ether 를 전송할 수 없다. 기본적으로 남은 gas 를 두 사용하나 지정해 줄 수 있다.

리턴 값

리턴 값는 call 과 동일하다.

특징

  1. msg.sender, msg.value 가 바뀌지 않는다. 즉 위 그림에서라면 처음 호출한 EOA 가 msg.sender 가 된다.
  2. 호출자의 storage 를 이용한다. 즉 A 의 storage 를 이용하기 때문에 B 의 실행 결과는 A 의 num1, num2 에 적용된다.

정리

call

  • 호출자 데이터 변경 x, 호출되는 대상의 데이터 변경 o
  • msg.sender, msg.value 변경 o

staticcall

  • call과 동일하나 storage 변경 x

delegatecall

  • 호출자의 데이터를 이용, 호출되는 대상은 데이터 변경 x
  • msg.sender, msg.value 변경 x

solution

따라서 Delegation 컨트랙트의 fallback 함수를 호출해야 하며 적절한 msg.data 를 제공하면 된다.

msg.data 에 들어가야 할 값 call data 인데 이 데이터는 어떤 함수를 호출할 것인지, 어떤 인자를 넣을 것인지 바이트로 나타내는 값이다. call data 중 맨 앞 4 바이트는 function selector 이고 나머지는 arguments 이다.

call data 는 복잡한 인코딩 과정을 거치지만 간단하게 web3 에서 제공하는 함수를 이용하면 만들어 낼 수 있다.

web3.eth.abi.encodeFunctionSignature('pwn()');

// results is "0xdd365b8b"

다음과 같은 object를 만든다.

const transaction = {
    to: contract.address,
    data: '0xdd365b8b', // 위에서 얻은 값
    gas: 2000000,
};

sendTransaction 한다.

await contract.sendTransaction(transaction);

이렇게 전송하면 Delegation 컨트랙트에서 pwn 과 일치하는 함수를 찾을 수 없으므로 fallback 함수를 건드리게 되고 delegatecall 의 인자로 '0xdd365b8b' 가 전송되어 타겟 컨트랙트의 pwn 함수를 호출한다.

이 때 Delegation 의 owner 를 이용하므로 해당 값이 바뀌게 되는 것을 확인할 수 있다!

profile
A kind of developer

0개의 댓글