Post

Dreamhack CTF (ERC1337)

개요

ERC1337 문제는 Third Contract 분석을 통해서 에 구조를 파악하여 취약점을 악용하여 Owner가 가지고 있는 토큰을 탈취하는 문제이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
contract Level {
    ERC1337 public token;
    uint256 public solved;
    
    constructor() {
        token = new ERC1337("DHT");
    }

    function solve() external {
        if (token.balanceOf(address(this)) == 1) {
            solved = 1;
        }
    }
}

ERC1337.sol 파일을 보면 Level이라는 컨트랙트 내에 solve() 함수가 있다. 함수를 보면 Level 컨트랙트의 token 잔액이 1이면 문제가 풀리는 것을 확인할 수 있다. 1 ether가 아닌 1이다.


ERC1337.sol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
contract ERC1337 is ERC20, EIP712, Nonces, ERC2771Context {
    bytes32 private constant PERMIT1_TYPEHASH = keccak256("Permit(string note,address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
    bytes32 private constant PERMIT2_TYPEHASH = keccak256("Permit(address origin,address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");

    error ERC2612ExpiredSignature();
    error ERC2612InvalidSigner();

    constructor(string memory name) ERC20(name, "") EIP712(name, "1") ERC2771Context(address(0)) {
        _mint(_msgSender(), 9999 ether);
    }

    function permitAndTransfer(
        string memory note,
        address owner,
        address spender,
        uint256 value,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) public {
        if (block.timestamp > deadline) {
            revert ERC2612ExpiredSignature();
        }

        if (!_verifySignatureType1(
            owner, 
            _hashTypedDataV4(keccak256(abi.encode(
                PERMIT1_TYPEHASH,
                note,
                owner,
                spender,
                value,
                _useNonce(owner),
                deadline
            ))), 
            v, r, s
        ) && !_verifySignatureType2(
            owner, 
            _hashTypedDataV4(keccak256(abi.encode(
                PERMIT2_TYPEHASH,
                tx.origin,
                owner,
                spender,
                value,
                _useNonce(owner),
                deadline
            ))), 
            v, r, s
        )) {
            revert ERC2612InvalidSigner();
        }

        _approve(owner, spender, value);
        _transfer(owner, spender, value);
    }

    function ecrecover(
        bytes32 hash,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) public pure returns (address) {
        return ECDSA.recover(hash, v, r, s);
    }

    function _verifySignatureType1(
        address owner,
        bytes32 hash,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) internal view returns (bool) {
        try this.ecrecover(hash, v, r, s) returns (address signer) {
            return signer == owner;
        } catch {
            return false;
        }
    }

    function _verifySignatureType2(
        address owner,
        bytes32 hash,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) internal view returns (bool) {
        try this.ecrecover(hash, v, r, s) returns (address signer) {
            return signer == _msgSender() || signer == owner;
        } catch {
            return false;
        }
    }

    function nonces(address owner) public view override(Nonces) returns (uint256) {
        return super.nonces(owner);
    }

    function DOMAIN_SEPARATOR() external view returns (bytes32) {
        return _domainSeparatorV4();
    }

    function isTrustedForwarder(address forwarder) public view override(ERC2771Context) returns (bool) {
        uint256 calldataLength = msg.data.length;
        uint256 contextSuffixLength = _contextSuffixLength() + 32;
        if (calldataLength >= contextSuffixLength && 
            bytes32(msg.data[calldataLength - contextSuffixLength:calldataLength - contextSuffixLength + 32]) == keccak256(abi.encode(name()))) {
            return true;
        } else {
            return super.isTrustedForwarder(forwarder);
        }
    }
}

ERC1337.sol 파일에 있는 ERC1337 컨트랙트이다. 보면 EIP712가 사용되고 있는 것을 볼 수 있는데, 이는 Typed Structured Data로 구조화된 데이터에 대한 해싱과 서명 검증을 위한 표준 프로토콜이다.

토큰을 다른 사용자에게 전송하기 위해서 permitAndTransfer() 함수를 사용할 수 있다. permitAndTransfer() 함수를 보면 조건문 내에 조건이 2개가 있지만 둘 중에 하나만 만족하면 된다. 그러나 _verifySignatureType1() 함수를 보면 서명된 메시지를 통해 복구한 주소와 owner 주소와 비교하는 것을 볼 수 있다. 이는 즉, 메시지를 owner의 개인키로 서명해야 한다는 것인데 이는 불가능하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
    function _verifySignatureType2(
        address owner,
        bytes32 hash,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) internal view returns (bool) {
        try this.ecrecover(hash, v, r, s) returns (address signer) {
            return signer == _msgSender() || signer == owner;
        } catch {
            return false;
        }
    }

그러나 _verifySignatureType2() 함수를 보면 누가봐도 이상한 로직이 있다. 복구한 주소를 그냥 owner 주소만을 가지고 비교하면 되는데 _msgSender() 함수의 반환 값을 복구한 주소와 비교하는 것을 볼 수 있다.


ERC2771Context.sol

1
2
3
4
5
6
7
8
9
    function _msgSender() internal view virtual override returns (address) {
        uint256 calldataLength = msg.data.length;
        uint256 contextSuffixLength = _contextSuffixLength();
        if (isTrustedForwarder(msg.sender) && calldataLength >= contextSuffixLength) {
            return address(bytes20(msg.data[calldataLength - contextSuffixLength:]));
        } else {
            return super._msgSender();
        }
    }

_msgSender() 함수는 ERC2771Context.sol 파일에 정의되어 있다. 코드를 보면 isTrustedForwarder() 함수를 호출하는 것을 볼 수 있는데 이는 ERC1337.sol 파일에 정의되어 있다.그리고 만약 조건이 부합하다면 전달받은 calldata의 마지막 20 바이트 값만 반환하는 것을 확인할 수 있다. 이 말은 위 조건을 맞춰주고, calldata의 마지막 20 바이트의 우리가 원하는 값을 넣는다면 _msgSender()의 반환 값을 조작할 수 있다는 뜻이다.

1
2
3
4
5
6
7
8
9
10
    function isTrustedForwarder(address forwarder) public view override(ERC2771Context) returns (bool) {
        uint256 calldataLength = msg.data.length;
        uint256 contextSuffixLength = _contextSuffixLength() + 32;
        if (calldataLength >= contextSuffixLength && 
            bytes32(msg.data[calldataLength - contextSuffixLength:calldataLength - contextSuffixLength + 32]) == keccak256(abi.encode(name()))) {
            return true;
        } else {
            return super.isTrustedForwarder(forwarder);
        }
    }

isTrustedForwarder() 함수를 보면 상위 32바이트의 값을 토큰의 이름을 ABI 인코딩하여 Keccak-256 해시한 값과 비교하는 것을 볼 수 있다. 그러니 calldata의 상위 32 바이트에는 토큰 이름은 DHT라는 값을 포함 시키면 된다.


EIP712.sol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
    bytes32 private constant TYPE_HASH =
        keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
    function _domainSeparatorV4() internal view returns (bytes32) {
        if (address(this) == _cachedThis && block.chainid == _cachedChainId) {
            return _cachedDomainSeparator;
        } else {
            return _buildDomainSeparator();
        }
    }

    function _buildDomainSeparator() private view returns (bytes32) {
        return keccak256(abi.encode(TYPE_HASH, _hashedName, _hashedVersion, block.chainid, address(this)));
    }

    /**
     * @dev Given an already https://eips.ethereum.org/EIPS/eip-712#definition-of-hashstruct[hashed struct], this
     * function returns the hash of the fully encoded EIP712 message for this domain.
     *
     * This hash can be used together with {ECDSA-recover} to obtain the signer of a message. For example:
     *
     * ```solidity
     * bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
     *     keccak256("Mail(address to,string contents)"),
     *     mailTo,
     *     keccak256(bytes(mailContents))
     * )));
     * address signer = ECDSA.recover(digest, signature);
     * ```
     */
    function _hashTypedDataV4(bytes32 structHash) internal view virtual returns (bytes32) {
        return MessageHashUtils.toTypedDataHash(_domainSeparatorV4(), structHash);
    }

ERC1337 내에서 주소 복구를 할 때, 서명과 함께 사용할 해시를 생성하는 방식은 EIP712.sol 파일 내에 있는 함수로 이루어진다. _buildDomainSeparator() 함수 내에서 해시 이름과 해시 버전은 ERC1337에서 상속되는 생성자를 보면 EIP712(name, "1") 와 같이 하고 있기 때문에 각 각, DHT, 1로 설정하면 된다.


Step by Step

  1. 먼저 ERC1337 내에서 사용하는 동일한 방식으로 해시를 생성한다.
  2. 생성한 해시로 임의의로 유효한 서명 값(v, r, s)를 생성한다. (vm.sign() 함수 이용)
  3. permitAndTransfer() 함수 호출을 위한 abi.encodeWithSelector를 생성하고, 토큰 이름인 DHT, 그리고 사용자 주소로, 32 + 20 바이트의 calldata를 생성한다.
  4. call() 함수를 호출해서 토큰 컨트랙트의 permitAndTransfer() 함수를 호출한다.

위 과정을 거치면 _msgSender()의 값으로 공격자의 주소가 반환되어 singer == _msgSender() 조건을 맞춰줄 수 있다.

This post is licensed under CC BY 4.0 by the author.