Ethernaut 21 Shop
Ethernaut 21 ShopEthernaut 21 ShopEthernaut 21 Shop 문제 풀이Ethernaut 21 Shop writeup
Contents
문제
지문
Can you get the item from the shop for less than the price asked? Things that might help: Shop expects to be used from a Buyer Understanding restrictions of view functions
코드
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IBuyer {
function price() external view returns (uint256);
}
contract Shop {
uint256 public price = 100;
bool public isSold;
function buy() public {
IBuyer _buyer = IBuyer(msg.sender);
if (_buyer.price() >= price && !isSold) {
isSold = true;
price = _buyer.price();
}
}
}배경지식
view 함수는 상태를 변경하지 않겠다고 선언한 함수다. 외부 컨트랙트의 view 함수를 호출하면 컴파일러는 보통 STATICCALL을 사용하고, 이 컨텍스트 안에서 상태 변경을 시도하면 revert된다.
이 문제에서 공격자의 price()는 내부 상태 변수 값을 바꾸는 방식으로 동작을 바꿀 수 없다. Elevator 문제처럼 bool 값을 뒤집으면서 첫 호출과 두 번째 호출의 반환값을 다르게 만드는 방식은 막힌다.
하지만 view 함수도 다른 컨트랙트의 현재 상태는 읽을 수 있다. 그래서 price() 안에서 Shop.isSold()를 읽고 그 값에 따라 다른 가격을 반환할 수 있다.
컨트랙트가 msg.sender를 인터페이스로 캐스팅해 함수를 호출하면, 실제 로직은 호출받는 컨트랙트가 결정한다. IBuyer.price() 타입이 정하는 것은 반환 타입과 view 제약뿐이고, 매번 같은 값을 반환한다는 보장은 없다.
Shop.buy()는 같은 외부 함수 price()를 두 번 호출하면서, 두 호출 사이에 isSold 상태를 바꾼다. 공격자는 이 상태 변화를 관찰해서 첫 번째 호출에는 조건을 통과할 값을, 두 번째 호출에는 실제 저장될 낮은 값을 반환할 수 있다.
문제 코드 분석
먼저 msg.sender를 구매자 컨트랙트로 취급하는 부분이다.
function buy() public {
IBuyer _buyer = IBuyer(msg.sender);
if (_buyer.price() >= price && !isSold) {
isSold = true;
price = _buyer.price();
}
}buy()는 msg.sender를 IBuyer로 캐스팅한 뒤 _buyer.price()를 호출한다. EOA에는 price() 함수가 없으므로, 공격 컨트랙트를 배포해서 그 컨트랙트가 buy()를 호출해야 한다.
Shop은 외부 컨트랙트가 반환하는 값을 그대로 신뢰한다. price()는 이름만 가격 조회 함수일 뿐, 반환값은 공격자가 원하는 로직으로 정할 수 있다.
첫 번째 price() 호출은 조건 통과에 쓰인다.
if (_buyer.price() >= price && !isSold) {처음 상태는 price = 100, isSold = false다. 따라서 조건문을 통과하려면 첫 번째 price() 호출이 100 이상을 반환해야 한다.
여기서는 isSold가 아직 false이므로 공격자의 price()가 Shop.isSold()를 읽고 100을 반환하면 조건을 만족한다. 이 호출은 상태를 바꾸지 않고 읽기만 하므로 view 제약에도 걸리지 않는다.
두 번째 price() 호출은 상태 변경 이후에 실행된다.
isSold = true;
price = _buyer.price();조건을 통과하면 Shop은 먼저 isSold를 true로 바꾸고, 그 다음 다시 _buyer.price()를 호출해서 price에 저장한다.
이 순서 때문에 두 번째 호출에서 공격자의 price()는 첫 번째 호출과 다른 환경을 보게 된다. 이제 Shop.isSold()가 true이므로 0을 반환하고, 최종적으로 Shop.price는 0이 된다.
풀이
Shop.buy()는 price()를 두 번 호출하고, 두 호출 사이에 isSold를 true로 바꾼다. 공격 컨트랙트의 price()는 상태를 직접 수정하지 않고 Shop.isSold()만 읽는다.
첫 번째 호출에서는 isSold == false이므로 100을 반환해 조건을 통과한다. 이후 Shop이 isSold = true로 바꾼 뒤 두 번째로 price()를 호출하면, 공격 컨트랙트는 0을 반환한다. 그 결과 최종 가격은 0으로 내려간다.
Elevator 문제처럼 공격 컨트랙트 안에 bool 상태 변수를 두고 호출마다 값을 뒤집는 방법은 price()가 view로 호출되기 때문에 쓸 수 없다. 대신 Shop 쪽에서 이미 바뀌는 isSold를 읽으면 같은 효과가 난다.
익스플로잇
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IShop {
function buy() external;
function isSold() external view returns (bool);
}
contract Attack {
IShop shop;
constructor(address _addr) {
shop = IShop(_addr);
}
function attack() public {
shop.buy();
}
function price() external view returns (uint256) {
if (shop.isSold() == false) return 100;
else return 0;
}
}
Challenge
Description
Can you get the item from the shop for less than the price asked? Things that might help: Shop expects to be used from a Buyer Understanding restrictions of view functions
Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IBuyer {
function price() external view returns (uint256);
}
contract Shop {
uint256 public price = 100;
bool public isSold;
function buy() public {
IBuyer _buyer = IBuyer(msg.sender);
if (_buyer.price() >= price && !isSold) {
isSold = true;
price = _buyer.price();
}
}
}Background
A view function declares that it will not change state. When you call an external contract's view function, the compiler usually uses STATICCALL, and attempting a state change within this context reverts.
In this challenge, the attacker's price() cannot change its behavior by modifying an internal state variable. The approach from the Elevator challenge, flipping a bool value so the first and second calls return different values, is blocked.
A view function can still read the current state of other contracts, though. So price() can read Shop.isSold() and return a different price depending on its value.
When a contract casts msg.sender to an interface and calls a function, the actual logic is decided by the called contract. The IBuyer.price() type only fixes the return type and the view constraint. Nothing guarantees that it returns the same value every time.
Shop.buy() calls the same external function price() twice, and changes the isSold state between the two calls. The attacker can observe this state change and return a value that passes the condition on the first call, and a low value that is actually stored on the second call.
Code analysis
First, the part that treats msg.sender as the buyer contract:
function buy() public {
IBuyer _buyer = IBuyer(msg.sender);
if (_buyer.price() >= price && !isSold) {
isSold = true;
price = _buyer.price();
}
}buy() casts msg.sender to IBuyer and then calls _buyer.price(). An EOA has no price() function, so you need to deploy an attack contract and have that contract call buy().
Shop trusts the value returned by the external contract as-is. The name price() suggests a price lookup, but the attacker's logic decides what it returns.
The first price() call is used to pass the condition.
if (_buyer.price() >= price && !isSold) {The initial state is price = 100, isSold = false. So to pass the conditional, the first price() call must return at least 100.
Here, since isSold is still false, if the attacker's price() reads Shop.isSold() and returns 100, the condition is satisfied. This call only reads and does not change state, so it does not trip the view constraint either.
The second price() call executes after the state change.
isSold = true;
price = _buyer.price();Once the condition passes, Shop first changes isSold to true, then calls _buyer.price() again and stores it in price.
Because of this ordering, on the second call the attacker's price() sees a different environment than the first. Now that Shop.isSold() is true, it returns 0, and Shop.price ends up as 0.
Solution
Shop.buy() calls price() twice and changes isSold to true between the two calls. The attack contract's price() does not modify state directly; it only reads Shop.isSold().
On the first call, isSold == false, so it returns 100 to pass the condition. Then after Shop changes isSold = true and calls price() a second time, the attack contract returns 0. As a result, the final price drops to 0.
The Elevator approach, keeping a bool state variable in the attack contract and flipping it on each call, does not work here because price() is called as view. Reading isSold, which Shop already changes, has the same effect.
Exploit
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IShop {
function buy() external;
function isSold() external view returns (bool);
}
contract Attack {
IShop shop;
constructor(address _addr) {
shop = IShop(_addr);
}
function attack() public {
shop.buy();
}
function price() external view returns (uint256) {
if (shop.isSold() == false) return 100;
else return 0;
}
}