学院 安全深度 文章

链上卫士:Paradigm CTF 2022 题目浅析—Sourcecode

2022.09.28 0xc730

题目背景

在Paradigm CTF 2022中有着这样一道DeFi金融赛题:

// SPDX-License-Identifier: UNLICENSED

pragma solidity 0.8.16;

contract Deployer {
    constructor(bytes memory code) { assembly { return (add(code, 0x20), mload(code)) } }
}

contract Challenge {

    bool public solved = false;

    function safe(bytes memory code) private pure returns (bool) {
        uint i = 0;
        while (i < code.length) {
            uint8 op = uint8(code[i]);

            if (op >= 0x30 && op <= 0x48) {
                return false;
            }

            if (
                   op == 0x54 // SLOAD
                || op == 0x55 // SSTORE
                || op == 0xF0 // CREATE
                || op == 0xF1 // CALL
                || op == 0xF2 // CALLCODE
                || op == 0xF4 // DELEGATECALL
                || op == 0xF5 // CREATE2
                || op == 0xFA // STATICCALL
                || op == 0xFF // SELFDESTRUCT
            ) return false;
            
            if (op >= 0x60 && op < 0x80) i += (op - 0x60) + 1;
            
            i++;
        }
        
        return true;
    }

    function solve(bytes memory code) external {
        require(code.length > 0);
        require(safe(code), "deploy/code-unsafe");
        address target = address(new Deployer(code));
        (bool ok, bytes memory result) = target.staticcall("");
        require(
            ok &&
            keccak256(code) == target.codehash &&
            keccak256(result) == target.codehash
        );
        solved = true;
    }
}

合约分析

题目总的要求:输入bytes类型的code,通过检查逻辑,返回solved=true

其中,所需要通过的逻辑可以分为两个部分

  1. 保证部署code后的codehash与code本身的keccak256结果相同,且调用code的返回值与code本身相同

Runtime code:合约运行时真正执行的code,它的keccak256的结果即是codehash

这里需要说明,我们只需要runtime code,而不需要平常所见合约的deploy code,因为要求了keccak256(code) == target.codehash && keccak256(result) == target.codehash

也就是说,code本身必须是runtime code

 address target = address(new Deployer(code));
(bool ok, bytes memory result) = target.staticcall("");
require(
    ok &&
    keccak256(code) == target.codehash &&
    keccak256(result) == target.codehash
);
  1. 保证code中不能出现safe 中禁止的OPCODE

能使用的主要OPCODE包括:POP MLOAD MSTORE MSTORE8 JUMP JUMPI PC MSIZE GAS JUMPDEST PUSH1~PUSH32 DUP1~DUP16 SWAP1~SWAP16 LOG0~LOG4

function safe(bytes memory code) private pure returns (bool) {
    uint i = 0;
    while (i < code.length) {
        uint8 op = uint8(code[i]);

        if (op >= 0x30 && op <= 0x48) {
            return false;
        }

        if (
               op == 0x54 // SLOAD
            || op == 0x55 // SSTORE
            || op == 0xF0 // CREATE
            || op == 0xF1 // CALL
            || op == 0xF2 // CALLCODE
            || op == 0xF4 // DELEGATECALL
            || op == 0xF5 // CREATE2
            || op == 0xFA // STATICCALL
            || op == 0xFF // SELFDESTRUCT
        ) return false;
        
        if (op >= 0x60 && op < 0x80) i += (op - 0x60) + 1;
        
        i++;
    }
    
    return true;
}

解题分析

明确解题思路

通常来说,为了完成第一个部分的逻辑,只需要进行codehash的直接获取即可,也就是使用操作码EXTCODEHASH或者使用EXTCODECOPY,但是这两个操作码并无法通过第二个部分的safe逻辑

因此,为了实现成功解题,回到最初的要求:

code本身能够将code存入内存中并返回,在这个过程中,不能出现上述的操作码

为了解决这个要求,模拟出来一个合约的执行场景:

  1. 将code本身作为参数压入栈
  2. 将code存入memory
  3. 将code的memory地址压入栈
  4. 使用return返回地址所对应的code

这里会发现一个问题,如果要使用整个的code作为参入压入栈,这种解法会不断的对code本身进行膨胀,因为随着push code作为参数,整个code会不断变长,递归修改下去

也就是说,code本身作为参数是不行的,那么是否可以将code的真正的执行逻辑部分,称为logic code,并作为参数push进去,另一部分执行这个logic,并用来进行最后的return返回,也就是:

  1. 将logic code作为参数压入栈
  2. 将logic code作为参数存入memory
  3. 执行logic code,包括:
    1. 将logic code的memory地址压入栈
    2. 使用return返回地址所对应的logic code

但这样就不是上面的膨胀了,而是减少了,也就是说返回的logic code和真正整个code本身相比,少了一份logic code,也就是说本应该返回logic code+logic code,现在只返回了logic code*1

所以最关键的地方地方在这里:使用DUP1,对logic code进行复制,即可返回logic code*2

简单来说就是,将logic code作为参数push一次,再作为真正逻辑执行一次,所以代码中需要出现两次,最后返回的是push进去的logic code与通过DUP1指令复制出来的logic code

尝试写出操作码

基于以上分析,写出大概的操作码流程:

PUSHX(X依赖于logic code的长度) logic code
----以下为整个所有的logic code----
DUP1(此时栈中有两段相同的logic code)

PUSH logic code要存的位置(初始,设置为00)
MSTORE(此时内存中有一段logic code,栈中有一段logic code)

PUSH 第二段logic code内存地址
MSTORE

PUSH logic code长度*2
PUSH 内存地址(00)
RETURN(返回)

也就是

60 ?
80
60 00
52
60 code length
52
60 code length*2
60 00
F3

写完逻辑后,从80开始即为code length,长度为12,即

6b(PUSH12) 8060005260125260246000F3
80
60 00
52
60 12
52
60 60(经过调试要长一些,因为存在着内存自动补0)
60 00
F3
也就是:6b8060015260215260416000f38060015260215260606000f3

执行结果:

执行结果

也就是,输入了两段,返回了两段,且没有用到禁止的操作码。

解决一些小问题

下面要解决的问题就是,如果处理当前返回值里这么多0?

一个很简单的方案就是,既然我们能够去做到输入两份返回两份,那么这些0用相应的不会影响执行的操作码去填充就好了,也就是填充最终的code,返回值里也有这些code,对以下的答案进行修改,用JUMPDEST操作码填充,在这个过程需要明确内存地址新增后的code长度相应变化

7f(PUSH32,因为填充变长) 
5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b80600152602152607f60005360416000f3
填充,有多少个00补多少5b:
5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b
80(DUP1)
60 01(将code存入,注意前面空出来,为后面的7f留地方)
52(MSTORE)
60 21(将code length长度流出来,存入DUP的结果)
52(MSTORE)
60 7f (这里需要注意,因为最后要返回的code中也包含PUSH32,也就是7f,所以要再前面为7f也存进去)
60 00(7f存入的位置)
53(MSTORE8,之所以不用MSTORE,是因为MSTORE存入了一个uint256的值,会留出大量0)
60 41 (整段代码长度)
60 00 (返回值起始位置,从00开始)
f3 (RETURN)

也就是:

7f5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b80600152602152607f60005360416000f35b5b5b5b5b5b5b5b5b5b5b5b5b5b5b80600152602152607f60005360416000f3

最终结果

最终结果

在remix中:

免责声明:OKLink学院仅提供信息参考,不构成任何投资建议。

相关推荐

security-insight