Curve Finance 漏洞复现

招新小广告CTF组诚招re、crypto、pwn、misc、合约方向的师傅,长期招新IOT+Car+工控+样本分析多个组招人有意向的师傅请联系邮箱

[email protected](带上简历和想加入的小组)

一、简介

智能合约在区块链的世界中较为重要。本文记录了笔者在复现 Python 智能合约编译器 Vyper 中的一个编译漏洞,该漏洞导致智能合约中的重入锁变得无效,进而使得合约易受重入攻击

二、环境搭建

1. Vyper 构建

下载 Vyper 编译器源代码并通过 pip 安装依赖。

git clone [email protected]:vyperlang/vyper.git
cd vyper

# 依赖来自 setup.py & requirements-docs.txt,不可直接照搬
pip3 install "asttokens>=2.0.5,<3" "pycryptodome>=3.5.1,<4" "semantic-version>=2.10,<3" "importlib-metadata" "wheel" "sphinx==4.5.0" "recommonmark==0.6.0" "sphinx_rtd_theme==0.5.2"

运行 python3 -m vyper --help,能正常输出帮助信息即可:

$ python3 -m vyper --help
usage: __main__.py [-h] [--version] [--show-gas-estimates] [-f FORMAT] [--storage-layout-file STORAGE_LAYOUT [STORAGE_LAYOUT ...]]
                   [--evm-version {istanbul,berlin,london,paris,shanghai,cancun}] [--no-optimize] [--optimize {gas,codesize,none}] [--debug] [--no-bytecode-metadata]
                   [--traceback-limit TRACEBACK_LIMIT] [--verbose] [--standard-json] [--hex-ir] [-p ROOT_FOLDER] [-o OUTPUT_PATH]
                   input_files [input_files ...]

Pythonic Smart Contract Language for the EVM

positional arguments:
  input_files           Vyper sourcecode to compile

options:
  -h, --help            show this help message and exit
  --version             show program's version number and exit
  ...

最后切换到漏洞引入点:

# https://github.com/vyperlang/vyper/commit/a09cdddd8ba249d1ce68ac31ec4496e50b8a25c7
git checkout a09cdddd

如果想要单步调试跟进,那就需要:

# 在 vyper 项目根目录下
cp ./vyper/__main__.py vyper.py
python3 vyper.py --help

三、漏洞根因

1. 安全的重入锁状态维护逻辑

在讲解漏洞根因之前,我们先来简单了解一下在引入漏洞 commit 之前,关于重入锁的状态维护逻辑

对于重入锁来说,自然是需要在 Storage 上有一个 slot 用来存放锁的状态。也就是 get_nonreentrant_lock 函数做的事情:

# 引入漏洞 commit 前
def get_nonreentrant_lock(func_type, global_ctx):
    nonreentrant_pre = [["pass"]]
    nonreentrant_post = [["pass"]]
    if func_type.nonreentrant:
        nkey = global_ctx.get_nonrentrant_counter(func_type.nonreentrant)
        nonreentrant_pre = [["seq", ["assert", ["iszero", ["sload", nkey]]], ["sstore", nkey, 1]]]
        nonreentrant_post = [["sstore", nkey, 0]]
    return nonreentrant_pre, nonreentrant_post

从代码中可以看到,当某个函数被标记为禁止重入时,vyper 会在需要用到重入锁的合约逻辑时,编译生成以上一系列的 IR。这些 IR 做的事情很简单,获取锁时检查锁是否为 0 && 将锁状态设置为 1;释放锁时重设锁状态为 0

而存放锁状态的 slot 是通过 global_ctx.get_nonrentrant_counter 函数所得,也就是那个在漏洞 commit 里被标记为 dead code 的函数,该函数会根据传入的 key 来确定要用哪个 slot 来存放锁状态:

def get_nonrentrant_counter(self, key):
    """
    Nonrentrant locks use a prefix with a counter to minimise deployment cost of a contract.

    We're able to set the initial re-entrant counter using the sum of the sizes
    of all the storage slots because all storage slots are allocated while parsing
    the module-scope, and re-entrancy locks aren't allocated until later when parsing
    individual function scopes. This relies on the deprecated _globals attribute
    because the new way of doing things (set_data_positions) doesn't expose the
    next unallocated storage location.
    """

    if key in self._nonrentrant_keys:
        return self._nonrentrant_keys[key]
    else:
        counter = (
            sum(v.size for v in self._globals.values() if not isinstance(v.typ, MappingType))
            + self._nonrentrant_counter
        )
        self._nonrentrant_keys[key] = counter
        self._nonrentrant_counter += 1
        return counter

而在函数重入中,这个 key 值是 vyper 脚本中的那个字符串,例如以下代码中的 lock 字符串,它用于区分开不同的重入锁:

@external
@nonreentrant('lock')
def add_liquidity() -> uint256:
    return 0

@external
@nonreentrant('lock')
def exchange() -> uint256:
   return 0

总结一句话,在引入漏洞 commit 之前,vyper 使用脚本里重入锁的字符串区分开不同的重入锁,而区分的方式是根据字符串来选择用于存放重入锁状态的 slot 位置。这样一来,倘若不同函数使用了相同名称的重入锁,则这些重入锁将会使用同一个 slot,来抵御重入攻击。

2. 带有漏洞的重入锁状态维护逻辑

引入漏洞前,vyper 用于存放重入锁状态的各个 slot 是直接追加在全局变量分配存储的末尾:

def get_nonrentrant_counter(self, key):
    if key in self._nonrentrant_keys:
        return self._nonrentrant_keys[key]
    else:
        # 注意这里的 counter 是怎么计算得出的
        counter = (
            sum(v.size for v in self._globals.values() if not isinstance(v.typ, MappingType))
            + self._nonrentrant_counter
        )
        self._nonrentrant_keys[key] = counter
        self._nonrentrant_counter += 1
        return counter

漏洞 commit 尝试将重入锁的状态变量与其他全局变量的分配合并掉,即在解析 vyper AST 阶段时就一并做掉重入锁的 slot 分配,而非在后续生成 IR 阶段时再去动态生成和指定重入锁的 slot 位置。因此 global_ctx.get_nonrentrant_counter 这个用来动态生成重入锁 slot 位置的函数就不再被调用了,被开发者标记为 dead code。而指定重入锁位置的重任则交付到了 set_storage_slots 函数上,该函数在 AST 解析阶段执行,其先前的作用只是用来指定各个变量存储的 slot 位置

Curve Finance 漏洞复现

从这里我们可以看到,在漏洞 commit 里 vyper 是怎么指定各个函数的重入锁所在 slot 呢?没错,它每个函数分配一个重入锁 slot,也就是说对于不同函数同名重入锁而言,这些重入锁相互之间不会阻止重入。

3. 漏洞演示

以下是一个关于该 vyper 重入漏洞的 POC:

@external
@nonreentrant('lock')
def add_liquidity() -> uint256:
    return 0

@external
@nonreentrant('lock')
def exchange() -> uint256:
   return 0

这个 POC 的逻辑很简单,它声明了两个不同的函数,但这两个函数使用了相同名称的重入锁。我们来输出它的 IR 看看:

输出 IR 命令:python3 vyper.py -f ir <vyper-script-path>

$ python3 vyper.py -f ir vyper_workdir/test.vy
[seq,
  [return,
    0,
    [lll,
      [seq,
        [if, [lt, calldatasize, 4], [goto, fallback]],
        [mstore, 28, [calldataload, 0]],
        [with,
          _func_sig,
          [mload, 0],
          [seq,
            [assert, [iszero, callvalue]],
            # Line 3
            [if,
              [eq, _func_sig, 3964006281 <add_liquidity()>],
              [seq,
                [assert, [iszero, [sload, 0]]],    # 检查重入锁状态
                [sstore, 0 /*slot*/, 1 /*val*/],   # 获取重入锁
                pass,
                # Line 4
                [mstore, 0, 0],
                [seq_unchecked, [sstore, 0, 0], [return, 0, 32]],
                # Line 3
                [sstore, 0, 0],                    # 释放重入锁
                stop]],
            # Line 8
            [if,
              [eq, _func_sig, 3539412570 <exchange()>],
              [seq,
                [assert, [iszero, [sload, 1]]],    # 检查重入锁状态
                [sstore, 1, 1],                    # 获取重入锁
                pass,
                # Line 9
                [mstore, 0, 0],
                [seq_unchecked, [sstore, 1, 0], [return, 0, 32]],
                # Line 8
                [sstore, 1, 0],                    # 释放重入锁
                stop]]]],
        [seq_unchecked, [label, fallback], /* Default function */ [revert, 0, 0]]],
      0]]]

可以看到那两对 sstore 指令使用的 slot 不是同一个,第一个函数使用了 slot0,而第二个函数使用了 slot1。

4. 漏洞修复

漏洞补丁(https://github.com/vyperlang/vyper/commit/eae0eaf8#diff-bbb2d32046e0a730536ca9e7d0b871e3765826115fc9f0c0228ddf08f171dde6R35)很简单,只允许在出现不同名的重入锁时才使用新的 slot:

Curve Finance 漏洞复现

五、参考

  • Curve Finance Analysis and Post-mortem – medium(https://medium.com/chainlight/curve-finance-analysis-and-post-mortem-ba55f2b26909)

– END –

Curve Finance 漏洞复现

原文始发于微信公众号(ChaMd5安全团队):Curve Finance 漏洞复现

版权声明:admin 发表于 2023年10月31日 上午8:00。
转载请注明:Curve Finance 漏洞复现 | CTF导航

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...