一道有趣而又令人迷惑的智能合约 CTF 题目

最近发现了一道叫 chaingang 的题目,来自 34C3 CTF. 是一道老题了,也能找到一些题解,不过读了一些题解后,我发现这道题还有一些没有被怎么讨论过的有趣细节,不如写一写.

题目本身并不难,这里有两篇相当详细的分析可以先读一读:

  1. 智能合约逆向心法 1(案例篇)——34C3_CTF 题目分析
  2. 智能合约逆向心法 2(案例篇)——34C3_CTF 题目分析续篇

读完之后可以对这道题有一个整体的了解.概括地来说,已有的题解给出了两种获取 flag 的方式 (CTF 题目的解题目标就是获得 flag):

  1. 在已经有人解出这道题后, flag 可以在区块链浏览器上读到.具体而言, flag 就是这笔交易的输出 (交易哈希是 0x70e9... ).当然,作为一道 CTF 题目,直接把 flag 放到链上是个很不明智的做法,这会让后来者可以很方便地"搭车".
  2. 那么第一个解出的人是怎么做的呢?反编译题目合约后分析各函数的功能可以发现,预期的解法应该是:第一步,调用 Receive() 并转入题目要求的金额;第二步,调用函数选择器为 0x2a0f7696 的函数, flag 就在它的返回值里.查看区块链浏览器上与该合约相关的交易记录可以看到第一个解出的本题的人的解题流程,他的第一步是哈希为 0x79a9... 的交易,第二步是哈希为 0x70e9...交易.

但我发现了一些疑点.首先,题目给出了这样的要求:

send 1505 szabo 457282 babbage 649604 wei 0x949a6ac29b9347b3eb9a420272a9dd7890b787a3

这就是我们要发送给题目合约的金额.但这究竟是多少 ether 呢?我用 web3.js 自带的工具算出来是 0.001505000457931604 ether, 上述分析文章的作者算出来也是这个值.但区块链浏览器显示的交易详情表明,第一个解出的人发送的金额其实是 0.001505457282649604 ether, 这是为什么?

其次,阅读反编译合约产生的伪代码后,可以推测 Receive() 函数其实是计算出了一个值存到了一个 mapping 类型的变量中,而 0x2a0f7696 函数(即 func_00CC() )则返回该变量的值.如果解题方法正确,该值就是 flag. 这似乎意味着,我们并不真的需要和题目合约交互,只需要逆向分析出计算 flag 的算法,就可以在本地算出 flag. (毕竟题目合约是部署在主网络上的,本地算 flag 还能节省一笔钱)

为实现这一目的,我们需要关注 Receive() 函数.这是它的伪代码:

function Receive() {
    var var0 = 0x00;
    var var1 = var0;
    var var2 = 0x02;
    memory[memory[0x40:0x60] + 0x20:memory[0x40:0x60] + 0x20 + 0x20] = 0x00;
    var temp0 = memory[0x40:0x60];
    memory[temp0:temp0 + 0x20] = msg.value;
    var var3 = temp0 + 0x20;
    var temp1 = memory[0x40:0x60];
    var temp2;
    temp2, memory[temp1:temp1 + 0x20] = address(var2).call.gas(msg.gas - 0x646e)(memory[temp1:temp1 + var3 - temp1]);

    if (!temp2) { revert(memory[0x00:0x00]); }

    var temp3 = memory[memory[0x40:0x60]:memory[0x40:0x60] + 0x20] ~ storage[0x01];
    memory[0x00:0x20] = msg.sender;
    memory[0x20:0x40] = 0x02;
    storage[keccak256(memory[0x00:0x40])] = temp3;
}

有两个地方特别有趣.其一,第 11 行调用了一个外部合约,而该合约的地址竟然是 0x2 !这是什么合约?其二,第 15 行的位取反操作符居然接受了两个操作数!估计是反编译器出错了.

我们不妨看看其他反编译器的结果.这是 Contract Library 的:

function Receive() public {
  require(sha256hash(msg.value, 0x0));
  owner_2[address(msg.sender)] = uint256((msg.value ^ stor_receive));
  exit();
}

这是 Eveem 的(是 Vyper 伪代码):

def unknown9f1b3bad() payable: 
  hash = sha256hash(call.value) # precompiled
  require sha256hash.result
  stor2[caller] = hash xor uint256(stor1)

这两段伪代码里都没有那个奇怪的外部合约调用,取而代之的是一个 SHA-256 计算.另外,位取反也变成了异或,看起来更合理.

那么 0x2 合约到底是什么呢?其实,这是个"预编译合约".所谓预编译合约,就是事先编译好,内置在以太坊客户端中的合约,可作为智能合约的"共享库"使用.占据 0x2 地址的预编译合约的功能就是计算  SHA-256 (注意区别, SHA-256 属于 SHA-2 家族,而常见的 Keccak-256  属于 SHA-3 家族,可视为 SHA3-256 的变种).这里有几篇关于预编译合约的资料: List of pre-compiled contractsprecompiles & solidityWhat's a precompiled contract and how are they different from native opcodes?

预编译合约是怎么工作的呢?编写一个简单的合约:

pragma solidity ^0.4.23;

contract hash {
    function getHash() public pure returns (bytes32, bytes32) {
        bytes32 a = keccak256("123");
        bytes32 b = sha256("123");
        return (a, b);
    }
}

编译得到字节码,再反编译,得到伪代码:

function func_0088() returns (var r0, var r1) {
    r1 = 0x00;
    var var1 = r1;
    var var3 = 0x00;
    var temp0 = memory[0x40:0x60];
    memory[temp0:temp0 + 0x20] = 0x3132330000000000000000000000000000000000000000000000000000000000;
    var temp1 = memory[0x40:0x60];
    var var2 = keccak256(memory[temp1:temp1 + (temp0 + 0x03) - temp1]);
    var var4 = 0x02;
    var temp2 = memory[0x40:0x60];
    memory[temp2:temp2 + 0x20] = 0x3132330000000000000000000000000000000000000000000000000000000000;
    var var5 = temp2 + 0x03;
    var temp3 = memory[0x40:0x60];
    var temp4;
    temp4, memory[temp3:temp3 + 0x20] = address(var4).call.gas(msg.gas)(memory[temp3:temp3 + var5 - temp3]);
    var var6 = !temp4;

    if (!var6) {
        var4 = memory[0x40:0x60];
        var5 = returndata.length;

        if (var5 < 0x20) { revert(memory[0x00:0x00]); }

        r1 = memory[var4:var4 + 0x20];
        r0 = var2;
        return r0, r1;
    } else {
        var temp5 = returndata.length;
        memory[0x00:0x00 + temp5] = returndata[0x00:0x00 + temp5];
        revert(memory[0x00:0x00 + returndata.length]);
    }
}

其中的 0x3132330000000000000000000000000000000000000000000000000000000000 就是十六进制编码的字符串 "123" .在伪代码中可以看到, keccak256() 函数被保留了下来,这是因为在 EVM 中有一个专门的操作码用于计算 Keccak-256, 对应于 Solidity 里的 keccak256() 函数.而 sha256() 函数是靠预编译合约而不是原生的操作码实现的,因此被编译成了向 0x2 合约的外部调用.

这样,题目合约计算 flag 的算法就一目了然了:计算 msg.value 的 SHA-256 哈希,并与 storage[0x01] 异或(可以把 storage 变量视为一个数组的元素.这些变量的值可以用 web3.js 读出来).因此,我们确实可以在不与题目合约交互的情况下做出这道题.

接下来,可以利用异或运算的性质算出我们究竟需要向合约发送多少金额,解答之前的疑问.若 hash(msg.value) ^ storage[0x01] = flag ,则 flag ^ storage[0x01] = hash(msg.value) ,并且我们已经知道了 flag 和 storage[0x01] .经过简单的计算可以得出, hash(msg.value) 等于 hash(1505457282649604) ,换句话说,第一个做出这道题的人发送的金额确实就是合约要求的金额,而我和那篇分析文章的作者都算错了.

这是怎么回事呢?我是用 web3.js 算的,难不成这玩意有 bug? 查了一些资料后,我发现一个有趣的现象: babbage 这个单位该怎么换算似乎没有统一的定论.有的说 1 babbage 等于 1Kwei, 有的说 1 babbage 等于 1Mwei (比如这个换算工具).目前 web3.js 采用的是 Kwei, 而这道题用的是 Mwei, 单位不统一真是害死人啊

(在早期的 web3.js 里, 1 babbage 曾经等于 1 Mwei, 后来似乎经过了来来回回几次修改.现在 web3.js 的单位换算功能是由一个叫 ethjs-unit 的包提供的,这玩意把 1 babbage 定义为了 1 Kwei)

以上.

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注