(수정중)
// SPDX-License-Identifier: MIT
pragma solidity ^0.5.0;
import '../helpers/Ownable-05.sol';
contract AlienCodex is Ownable {
bool public contact;
bytes32[] public codex;
modifier contacted() {
assert(contact);
_;
}
function make_contact() public {
contact = true;
}
function record(bytes32 _content) contacted public {
codex.push(_content);
}
function retract() contacted public {
codex.length--;
}
function revise(uint i, bytes32 _content) contacted public {
codex[i] = _content;
}
}
You've uncovered an Alien contract. Claim ownership to complete the level.
Things that might help
Understanding how array storage works
Understanding ABI specifications
Using a very underhanded approach
ownership 가져오기!
값이 false면 에러를 발생시킨다. 내부적 에러 테스트 용도, 불변성 체크 용도로 사용 하는 것이 적합하다.
solidity에서 delete
로 array 항목을 지우면 그 항목이 완전히 지워지는 게 아니라 default 값으로 돌아간다. uint256
의 경우 default 값이 0이기 때문에 아래 코드에서도 0으로 변한다. 반면 length--
는 가장 마지막에 있는 항목을 제거한다.(하지만 0.8.0 버전에서는 오류가 나기 때문에 배열의 마지막 항목을 제거할 때 array.pop()
을 사용하자.)
uint256[] public testArray = [1,2,3,4,5];
// testArray = [1,2,3,4,5]
// testArray.length = 5
delete testArray[4];
// testArray = [1,2,3,4,0]
// testArray.length = 5
testArray.length--
// testArray = [1,2,3,4]
// testArray.length = 4
먼저 컨트래트가 상속하고 있는 Ownable 컨트랙트를 살펴보자. Openzeppelin docs에서 볼 수 있다.
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v4.7.0) (access/Ownable.sol)
pragma solidity ^0.8.0;
import "../utils/Context.sol";
/**
* @dev Contract module which provides a basic access control mechanism, where
* there is an account (an owner) that can be granted exclusive access to
* specific functions.
*
* By default, the owner account will be the one that deploys the contract. This
* can later be changed with {transferOwnership}.
*
* This module is used through inheritance. It will make available the modifier
* `onlyOwner`, which can be applied to your functions to restrict their use to
* the owner.
*/
abstract contract Ownable is Context {
address private _owner;
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
/**
* @dev Initializes the contract setting the deployer as the initial owner.
*/
constructor() {
_transferOwnership(_msgSender());
}
/**
* @dev Throws if called by any account other than the owner.
*/
modifier onlyOwner() {
_checkOwner();
_;
}
/**
* @dev Returns the address of the current owner.
*/
function owner() public view virtual returns (address) {
return _owner;
}
/**
* @dev Throws if the sender is not the owner.
*/
function _checkOwner() internal view virtual {
require(owner() == _msgSender(), "Ownable: caller is not the owner");
}
/**
* @dev Leaves the contract without owner. It will not be possible to call
* `onlyOwner` functions anymore. Can only be called by the current owner.
*
* NOTE: Renouncing ownership will leave the contract without an owner,
* thereby removing any functionality that is only available to the owner.
*/
function renounceOwnership() public virtual onlyOwner {
_transferOwnership(address(0));
}
/**
* @dev Transfers ownership of the contract to a new account (`newOwner`).
* Can only be called by the current owner.
*/
function transferOwnership(address newOwner) public virtual onlyOwner {
require(newOwner != address(0), "Ownable: new owner is the zero address");
_transferOwnership(newOwner);
}
/**
* @dev Transfers ownership of the contract to a new account (`newOwner`).
* Internal function without access restriction.
*/
function _transferOwnership(address newOwner) internal virtual {
address oldOwner = _owner;
_owner = newOwner;
emit OwnershipTransferred(oldOwner, newOwner);
}
}
transferOwnership
으로 ownership을 가져올 수 있을 것 같다. 하지만 해당 함수는 owner만 호출할 수 있다. Ownerable 컨트랙트를 통해 알 수 있는 것은 Storage의 첫 번째 slot에 owner가 할당된다는 것이다. boolean 타입인 contact와 address 타입인 owner가 같이 할당될 것이다.
Storage는 초기에 0으로 채워진 매우 큰 Array라고 생각할 수 있다. 각 칸에는 32bytes의 데이터를 담을 수 있고, 모든 칸의 갯수는 2^256개다. 0은 공간을 차지하지 않는다. 따라서 나중에 value 값을 0으로 바꿔서 해당 공간을 회수할 수 있다. 이러한 방식은 나중에 gas를 환불할 때 사용된다.
일반적으로 크기가 정해진 변수는 컴파일 과정에서 Storage의 slot에 순서대로 할당된다. 크기가 정해져 있기 때문에 32bytes에 맞춰서 각 slot에 들어간다.
하지만 크기가 정해져 있지 않고 변할 수 있는 Dynamic Array이나 mapping의 경우 일반적인 변수와 같은 방법으로 slot에 할당할 수 없다. 길이를 알 수 없기 때문에 무작정 할당할 수 없기 때문. 2^256개나 되는 엄청난 양의 slot이 있기 때문에 랜덤으로 배치되도 상관없긴 하다. 하지만 해당 slot을 다시 찾기 어려울 수 있으니 좀 더 적절한 방법이 필요하다. 솔리디티는 이러한 상황에서 keccak256
을 이용한다.
위 코드의 경우, Dynamic Array의 길이값만 slot5에 할당하고 나머지 value는 hash(5)
를 통해 나온 값을 시작 위치로 지정해 순서대로 할당한다. 구체적인 로직은 다음과 같다. 일반적인 mapping이나 dynamic array가 같이 있는 mapping이 어떻게 할당되는지 궁금하다면 이 글을 참고하자.
function arrLocation(uint256 slot, uint256 index, uint256 elementSize)
public
pure
returns (uint256)
{
return uint256(keccak256(slot)) + (index * elementSize);
}
현재 codex
array의 길이는 0이다. 그런데 retract()
함수로 -1을 하면 어떻게 될까? codex
의 길이가 최대치가 된다. 이게 Underflow 공격이다. 처음엔 배열의 길이가 최대치가 되는게 왜 공격인지 이해가 안 됐었다. 길이가 늘어나는 거랑 무슨 상관인지 몰랐다. 하지만 조금 더 생각해보니 엄청난 공격이었다. 앞서 얘기했던 것처럼 Storage는 총 2^256개의 slot을 가지고 있다. Dynamic array의 크기는 정해져 있지 않기 때문에 keccak256
을 통해 나온 값을 시작 위치로 해서 Data value가 slot에 할당된다. 그런데 Dynamic array의 크기가 최대치가 돼서 Storage 전체 크기와 같다면? 말 그대로 array의 모든 data가 모든 slot에 할당된다고 할 수 있다. 그런데 이번 예제처럼 첫 번째 슬롯에 contact
값과 owner
값이 들어있다면 해당 데이터에도 접근할 수 있게된다. 어떻게 이게 가능할까? array의 index에 접근해서 값을 바꾸기만 하면 되기 때문이다. Dynamic array의 길이값을 Storage 전체 크기로 만들었기 때문에 모든 slot에 접근이 가능해진다. 이제 revise()
함수로 인덱스 값과 우리의 메타마스크 주소를 넣어주면 ownership을 가져올 수 있다.