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
- 먼저 ERC1337 내에서 사용하는 동일한 방식으로 해시를 생성한다.
- 생성한 해시로 임의의로 유효한 서명 값(v, r, s)를 생성한다. (vm.sign() 함수 이용)
- permitAndTransfer() 함수 호출을 위한 abi.encodeWithSelector를 생성하고, 토큰 이름인 DHT, 그리고 사용자 주소로, 32 + 20 바이트의 calldata를 생성한다.
- call() 함수를 호출해서 토큰 컨트랙트의 permitAndTransfer() 함수를 호출한다.
위 과정을 거치면 _msgSender()
의 값으로 공격자의 주소가 반환되어 singer == _msgSender()
조건을 맞춰줄 수 있다.