dapp安全总结与典型安全事件

2022-11-07 10:02:24 浏览数 (1)

本文作者:493lab_jhys[1]

以太坊以及 EVM 的诞生使得 Dapp这种新的业务形态成为可能。总的来说,EVM 实现了一个全局的状态机,为所有的 Dapp提供了统一的状态空间;实现了图灵完备,并抽象出了账户模型,账户之间可以相互调用,使得不同的应用可以无缝组合,展现了 Dapp的独特魅力。

上图为 Dapp的技术栈,用户的交易请求通过共识网络和区块数据结构驱动状态机的更新;公共的状态空间以及账户模型下的组合性,可以很方便地和最大限度地集合群体智慧,使得 Dapp具有无限的可能性。

但任何事物都具有两面性,新的业务形态也带来了复杂的安全形势。Dapp的开发基于密码学、账户模型、公共账本数据库和状态机、通证经济学等,与以前基于中心化数据库和服务器的 app,有很大不一样。比如:

  1. 不同合约的相互调用带来了可组合性,也带来未知的逻辑,对于一个合约来说,调用其它合约,特别是当被调用的合约地址可以从外部输入时,相当于一个完整逻辑从中间断开,对合约安全的影响很难把握。
  2. 一些新工具的诞生,如闪电贷,使得外部调用可能带来的安全问题更具威胁性。
  3. 与以前中心化的 C/SB/S应用相比,Dapp的数据库、状态机和业务逻辑代码都是开放的,网上的任何用户几乎都可以获取到 Dapp的全部信息,来寻找合约的漏洞。

原理

dapp来说,既有人为因素和网络钓鱼等传统网络安全问题,又有新的技术和应用场景带来的新的问题,下面主要分析下这些新的问题。

共识层相关

POW 的 51%攻击

在基于 POW共识的区块链系统中,矿工们通过求解密码学难题来竞争新区块的记账权。不同矿工节点间比拼的是算力,谁拥有更高的算力,谁就越有可能可能当前区块的记账权。区块组成链,更长的链代表经历了更多的算力,这就形成了“最长链法则”。

正常情况下,矿工需要基于最长链挖出的区块才会被认可。但是当某个矿工拥有全网一半以上的算力时,他就可以按照自己的需要控制新区块的产出,以及最长链的走向。而这样就可以实现双花了。

下面已具体的例子说明

  1. 攻击者控制 Bitcoin Gold 网络上 51%以上的算力,在控制算力的期间,他把一定数量的 BTG 发给自己在交易所的钱包,这条分支我们命名为分支 A。
  2. 同时,他又把这些 BTG 发给另一个自己控制的钱包,这条分支我们命名为分支 B。
  3. 分支 A 上的交易被确认后,攻击者立马卖掉 BTG,拿到现金。这时候,分支 A 成为主链。
  4. 然后,攻击者在分支 B 上进行挖矿,由于其控制了 51%以上的算力,那么攻击者获得记账权的概率很大,于是很快分支 B 的长度就超过了主链(也就是分支 A 的长度),那么分支 B 就会成为主链,分支 A 上的交易就会被回滚,将数据恢复到上一次正确的状态位置。
  5. 也就是说,分支 A 恢复到攻击者发起第一笔交易之前的状态,攻击者之前换成现金的那些 BTG 又回到了自己手里。
  6. 最后,攻击者把这些 BTG,发到自己的另一个钱包。就这样,攻击者凭借 51%以上的算力控制,实现同一笔 token 的“双花”。
Tendermint 的 1/3 攻击

tendermint共识中,需要 3f 1的总节点数,而要维持网络的正确运行,恶意节点不能超过 f个。从“上帝区块”开始,区块中已约定好后续的生产者名单序列,而后按照顺序生产区块。生产区块时,从 proposecommit需要 2个阶段:prevoteprecommit,且这两个阶段都需要 2/3以上的节点签名。下图为生产区块的流程图

当有 f 1个恶意节点时,便可以分别向余下的两 f节点分别发送不同的区块,从而使网络分叉,实现双花。

密码学相关

私钥恢复

使用钱包和区块链交互时,需要用保存在本地的私钥对消息进行签名,然后发给节点。其签名过程如下:

anyswap 便发生过这样的安全事件,见文末的链接

hash 碰撞

使用 solidity 开发智能合约时,合约方法在编译成字节码时,会使用其完整方法名的 hash 的前 4 个字节标记,例如 transfer(address,uint256)的标记为 0xa9059cbb。而要通过 hash 碰撞产生一个满足指定 4 字节标记的方法签名并不困难。

当合约中可以通过在参数中传入方法名来执行时,就可以通过 hash 碰撞来使用合约身份来执行指定方法,若合约开发者未考虑这种情况,则可能会带来未知风险。著名的 poly 网络攻击事件便是基于此进行攻击的。

重放攻击

根据EIP155[2],对交易进行签名,有两种形式:一是 (nonce, gasprice, startgas, to, value, data),这种情况下,签名的 v值为 {0,1} 27;二是 (nonce, gasprice, startgas, to, value, data, chainid, 0, 0),此时的 v值为 {0,1} CHAIN_ID * 2 35。这里的 {0,1}用来区分椭圆曲线上 x所对应的 y。上述两种形式的主要区别在于签名内容中是否带有 chainid

当前的区块链世界是一个多链并存的世界,且很多链都是基于以太坊的。对于不带 chainid的签名交易,我们可以把这条链上交易信息读取出来,然后发送到另一条链上去执行。导致重放攻击。最近的 op 代币被盗事件就是基于这样的方式。

出块相关

区块链的世界是一维单向的,当不同交易的顺序发生变化时,则状态机的状态变更也会有所不同。交易如何排序是由矿工决定的,这也使得矿工可以获取额外的利益。主要有以下三种获利方式(都是针对的同一区块中的交易):

  1. 抢跑,指通过让特定交易排在目标交易前而获利,主要针对清算和套利交易;
  2. 尾随,指通过让特定交易排在目标交易后而获利,主要针对预言机交易或大单交易;
  3. 三明治夹击,上述两种攻击形式的结合,让目标交易恰好夹在两笔特定构造交易中间,从而获利。三明治攻击大大拓宽了可攻击的范围,哪怕是一笔普通的 AMM DEX 交易,都有可能成为针对对象。攻击者的第一笔构造交易制造更大的交易价格波动,待目标交易执行完之后紧接着执行第二笔构造交易,换回发动攻击的代币完成获益。

交易排序问题进一步导致了 MEV(矿工可提取手续)问题,也是区块链发展的一个重要研究方向。

EVM 相关

操作码分类

  • 算术运算:ADD, MUL, SUB, DIV, SDIV, MOD, SMOD, ADDMOD, MULMOD, EXP, SIGNEXTEND
  • 逻辑运算:LT, GT, SLT, SGT, EQ, ISZERO
  • 位运算:AND, OR, XOR, NOT, BYTE, SHL, SHR, SAR
  • 当前交易状态信息:ADDRESS, SELFBALANCE, ORGIN, CALLER, CALLVALUE
  • 当前块状态信息:COINBASE, TIMESTAMP, NUMBER, DIFFICULTY, GASLIMIT, GASPRICE, BASEFEE
  • 当前链状态信息:CHAINID
  • 其它信息读取:BALANCE,BLOCKHASH
  • 栈相关:POP, PUSH[1-32], DUP[1-16], SWAP[1-16], PUSH, DUP, SWAP
  • CALLDATA 相关:CALLDATALOAD, CALLDATASIZE, CALLDATACOPY
  • 内存相关:MLOAD, MSTORE, MSTORE8
  • 持久存储相关:SLOAD, SSTORE
  • 流程控制相关:JUMP, JUMPI, PC, JUMPDEST, RETURN, REVERT
  • 执行时环境信息:MSIZE, GAS
  • 日志相关:LOG[0-4]
  • 合约创建相关:CREATE, CREATE2
  • CODE 相关:CODESIZE, CODECOPY, EXTCODESIZE, EXTCODECOPY, EXTCODEHASH
  • 外部调用相关:CALL, CALLCODE, DELEGATECALL, STATICCALL, RETURNDATASIZE, RETURNDATACOPY
  • 其它:STOP, SELFDESTRUCT, SHA3

可以看到,除了运算逻辑、存储逻辑、流程控制逻辑等常规的指令外,还有像交易状态信息读取、合约代码、创建和调用、自毁等独特的操作指令。这些特殊的指令的使用也带来新的风险。

重入

每个合约地址都有自己的代码,代表一个业务处理逻辑,不同的合约可以通过外部调用进行组合,创造更复杂的应用。但在进行外部调用的时候,也会把程序执行的控制权暂时转移到其它合约上,这会导致原本自身完整的逻辑被破坏,容易出现意想不到的情况。

比如某些合约可以进行质押和提款操作,提款时可能会产生重入问题,下面是一个例子:

代码语言:javascript复制
function withdrawBalance() {
  amountToWithdraw = userBalances[msg.sender];
  if (!(msg.sender.call.value(amountToWithdraw)())) { throw; }
  userBalances[msg.sender] = 0;
}

正常情况下,转账操作和修改余额的操作应该绑定在一起,具有原子性。但由于使用 call转账时,程序执行被转移到新的地址上了,原本逻辑的原子性受到破坏。导致转账发生了但余额未减,且此过程可以不断进行,最终把不属于自己的余额也转走了。

导致原来的 ETC 回滚硬分叉产生现在的 ETH 的 theDAO 事件就是一起典型的利用重入的安全事件,当然其真实的代码[3]要复杂些,但原理是一样的。

msg.value 的持久化问题

在委托调用中,msg.value的值被持久化,在某些批量操作的场景下,可能会被多次使用。比如在类似 opensea 的用于 nft 交易的市场合约中,可能有下面代码:

代码语言:javascript复制
function batch(bytes[] calldata calls, bool revertOnFail) external payable returns(bool[] memory successes,bytes[] memory results) {
    successes = new bool[](calls.length "");
    results = new bytes[](calls.length "");
    for (uint256 i = 0; i< calls.length; i  ){
        (bool success, bytes memory result)=address(this).delegatecall(calls[i]);
        require(success || !revertOnFail,_getReyerMsg(result));
        successes[i] = success;
        results[i] = result;
     }
 }

可以看到,若调用此合约进行 nft 的批量购买,则 msg.value可以重复使用。

随机数问题

在一些 gamefi 合约中,需要使用随机数来完成一些功能,而这些随机数的种子来源可能是一些区块的状态变量加上用户的一些输入,比如下面的代码:

代码语言:javascript复制
function rand(address _to, uint256 tokenId) public view returns (uint256) {
        uint256 random = uint256(
            keccak256(
                abi.encodePacked(
                    block.difficulty,
                    block.timestamp,
                    _to,
                    tokenId,
                    block.number
                )
            )
        );
        return random % 1000;
    }

若此合约中一些与资产操作有关的方法基于 rand方法时,用户可通过部署合约来提前得到随机数的值从而规避不利的随机数。

交易的原子性问题

区域区块链的每一笔交易,要么成功,要么失败。失败的话,所做的状态变更都会还原。在 gamefi 场景中,也可以得到利用。同样是上面的随机数场景,我们可以合约来进行相关操作,当最终结果不利时,可以让交易无效,来挽回损失。

SELFDESTRUCT 操作码

正常情况下,合约若要默认可接收 eth转账,则需提供 receive或者 fallback方法,但需注意 SELFDESTRUCT可强制转账到某合约,而不需要这两个方法。

主网代币与合约代币的区别

主网代币是记录在每个账户下的一个变量,可用于支付 gas;而合约代币是合约地址下的一个数据记录。两者的转账操作在处理上是不一样,在涉及到其操作的合约里,一定要注意区别处理。

合约地址与 EOA 地址的调用区别

调用合约时,需要合约有对应的方法,否则会报错;而非合约地址则没有这样的要求,只要余额和 gas 足够就行。在校验外部调用是否成功时,需要考虑这种情况。QBridge 安全事件就是基于此的。

链上难以有效判断一个地址为非合约

一个合约地址的 CODESIZE是大于零的,但当地址的 CODESIZE等于零时,并不能保证其为非合约,因为合约在构造阶段 CODESIZE也为零。

dapp 安全事件

defi 场景

xsurge 攻击事件

xsurge 是 bsc 上的 defi 协议,其代币合约[4]中提供了 sellpurchase方法用于使用 BNB买卖其代币 surge,但是其合约中存在价格计算缺陷和重入漏洞。

可以看到,在 sell方法中先转账,然后修改状态,而在转完 BNB 而 surge 余额未减去时,两者的兑换价格发生了突变,且由于 BNB 减少 surge 不变,一个 BNB 可以买更多的 surge。虽然 sell方法中有重入控制,但 purchase没有,重入控制只能阻止再次进入 sell方法,但依旧可以进入 purchase方法中进行购买操作。

黑客便是利用这个漏洞循环在交易[5]中循环进行买卖操作,每循环一次就能获取更多的 BNB

DAO 场景

Beanstalk Farms 安全事件

在这次攻击事件中,攻击者创建了一个恶意提案,通过闪电贷获得了足够多的投票,并执行了该提案,从而从协议中窃取了资产,总共获利差不多 8000 万美金。详细的过程见之前写的文章[6]

Fortress Loans 安全事件

Fortress Loans 协议是一个借贷协议,且通过 DAO治理,FTS是其治理代币,该协议在代码层面和经济层面都存在一些问题。

  1. 对 FTS 的价格获取存在漏洞,可以被任意修改,这对借贷协议来说是致命的
  2. 协议治理中,执行提案的 FTS 要求仅为总量的 4%,且价格低,兑换成本仅为 11ETH

于是,黑客提交恶意提案,将 FTS 加入担保资产,并控制其价格,得以从协议中借出远超其担保物真实价值的资产,获利离场。详细的过程见之前写的文章[7]

跨链场景

poly 网络攻击事件

Poly network 是一个跨链网络,在这次事件被盗 6.1 亿美元

跨链原理

上图介绍了从 A 链跨链到链 B 的详细流程,用户在链 A 发起跨链请求,调用了 DApp 的跨链接口,最终会在 B 链的 DApp 合约得到用户想要的结果。A 链和 B 链实现了上文的两本合约及其接口,任何人都可以围绕跨链管理合约建立稳定可用的跨链 DApp,分别在 A 链和 B 链部署业务合约,这些合约会组成一个完整的跨链 DApp

  1. 用户调用 A 链的业务合约,合约会进一步调用跨链管理合约,传递用户的跨链参数,跨链管理合约会创建跨链交易,随着 A 链出块,交易落账;
  2. 由于链与链之间是不会主动交换信息的,所以需要一个 Relayer 去传递信息,Relayer 会把 A 链的区块头同步到中继链的区块头同步合约,然后从 A 链的存储中取出跨链管理合约返回的事件,其中包含用户的跨链参数,再获取跨链交易的 Merkle Proof,一并转发给中继链的跨链管理合约;
  3. 中继链的跨链管理合约会读取 A 链的区块头,验证跨链参数的 Proof 是否正确,验证通过后,会将 B 链需要的跨链信息以事件的形式返回;
  4. B 链的 Relayer 会将中继链区块头同步到 B 链的区块头同步合约,然后从中继链的账本中获取到 B 链的跨链参数和其 Merkle Proof,提交到 B 链的跨链管理合约;
  5. 链 B 的跨链管理合约验证跨链信息的正确性,然后调用信息里的目标合约,完成跨链合约的调用;

上面的流程中,共有两个 Merkle Proof,第一个证明了来自 A 链跨链信息确实存在于 A 链,第二个则证明了跨链信息确实存在于中继链,如此便建立了跨链的信任机制。这就是跨链 DApp 的运行流程,所有的侧链仅需和中继链生态交互即可。

潜在问题

同一条链上的转账交易具有原子性,但当需要跨链时,其原子性被打破了,转入和转出发生在不同的链上。当然这样说其实并不太恰当,转入与转出在各自的链上都是一笔完整的转账操作,只是通过由各自链上合约进行资金托管的方式进行隐藏。

对两个特定链的跨链来说,各自的合约需要实现对方的签名验证,再加上第三方同步两条链的区块与交易。此过程对于签名验证来说,并没有引入额外的风险,也就是说贯穿始终的还是发起方的交易签名。

上述的两链之间的直接跨链实际使用很受限。为了实现跨链的通用性,需要引入一条专门的链,其它的链都之和它进行跨链。这样的话,跨链转账交易的流程更长了,而且更为重要的是引入了 额外的风险 。即中间链的担保效应,源链的转入证明不再由目标链上的合约直接验证,而是由中间链验证,再由中间链进行担保,目标链上的合约对担保进行验证。此次 poly 攻击也是从这里入手的。

此次攻击的关键点

前面提到,使用中继链后,资金的安全实际上依赖于中继链的多个验证人(也就是 keepers)。正常情况下,这不会有什么问题,中继链会验证源链的签名,目标链验证中继链 keepers的签名,用户只能使用自己的资金。但由于合约存在缺陷,使得 keepers被修改,黑客可以使用协议中的所以资金。

从上图我们可以看出 _executeCrossChainTx 函数未对传入的 _toContract、_method 等参数进行检查就直接以 _toContract.call 的方式执行交易。通过hash 碰撞构造特定的方法签名,则可以以管理合约的身份执行一些特殊的方法。而该管理合约也正好提供了 putCurEpochConPubKeyBytes 函数可以直接修改 keepers公钥。关于此处攻击的细节见慢雾的分析。

QBridge 安全事件

在这次事件中,黑客获利 8000 万美元。

QBridge 是一个跨链协议,但其合约存在两个缺陷:一个是在跨链 deposit 时,对主网代币和 erc20 代币虽然提供了不同的方法,但并未做严格的限制;另一个是在做转账调用时,并未考虑合约地址和 EOA 地址的区别。

QBridge 合约

代码语言:javascript复制
function deposit(uint8 destinationDomainID, bytes32 resourceID, bytes calldata data) external payable notPaused {
        require(msg.value == fee, "QBridge: invalid fee");

        address handler = resourceIDToHandlerAddress[resourceID];
        require(handler != address(0), "QBridge: invalid resourceID");

        uint64 depositNonce =   _depositCounts[destinationDomainID];

        IQBridgeHandler(handler).deposit(resourceID, msg.sender, data);
        emit Deposit(destinationDomainID, resourceID, depositNonce, msg.sender, data);
    }

    function depositETH(uint8 destinationDomainID, bytes32 resourceID, bytes calldata data) external payable notPaused {
        uint option;
        uint amount;
        (option, amount) = abi.decode(data, (uint, uint));

        require(msg.value == amount.add(fee), "QBridge: invalid fee");

        address handler = resourceIDToHandlerAddress[resourceID];
        require(handler != address(0), "QBridge: invalid resourceID");

        uint64 depositNonce =   _depositCounts[destinationDomainID];

        IQBridgeHandler(handler).depositETH{value:amount}(resourceID, msg.sender, data);
        emit Deposit(destinationDomainID, resourceID, depositNonce, msg.sender, data);
    }

handler 合约

代码语言:javascript复制
function deposit(bytes32 resourceID, address depositer, bytes calldata data) external override onlyBridge {
        uint option;
        uint amount;
        (option, amount) = abi.decode(data, (uint, uint));

        address tokenAddress = resourceIDToTokenContractAddress[resourceID];
        require(contractWhitelist[tokenAddress], "provided tokenAddress is not whitelisted");

        if (burnList[tokenAddress]) {
            require(amount >= withdrawalFees[resourceID], "less than withdrawal fee");
            QBridgeToken(tokenAddress).burnFrom(depositer, amount);
        } else {
            require(amount >= minAmounts[resourceID][option], "less than minimum amount");
            tokenAddress.safeTransferFrom(depositer, address(this), amount);
        }
    }

    function depositETH(bytes32 resourceID, address depositer, bytes calldata data) external payable override onlyBridge {
        uint option;
        uint amount;
        (option, amount) = abi.decode(data, (uint, uint));
        require(amount == msg.value);

        address tokenAddress = resourceIDToTokenContractAddress[resourceID];
        require(contractWhitelist[tokenAddress], "provided tokenAddress is not whitelisted");

        require(amount >= minAmounts[resourceID][option], "less than minimum amount");
    }

攻击交易[8]

  1. 攻击者指定传入的 resourceID 为跨 ETH 代币所需要的值,但其调用的是 QBridge 的 deposit 函数而非 depositETH 函数
  2. handler 合约的 deposit 函数中会根据 resourceID 取出的所要充值的代币,而 ETH 对应的所要充值的代币为 0 地址
  3. 由于所要充值的代币地址为 0 地址,而 call 调用无 code size 的 EOA 地址时其执行结果都会为 true 且返回值为空,因此通过了 safeTransferFrom 的检查,最后触发了 Deposit 跨链充值事件
Optimism 安全事件

此次事件,黑客获利 2000 万 op 代币

前面的“重放攻击”章节中提到,对于 evm 生态来说,当一笔交易签名的 v 值为 27 或 28 时,则签名信息中不包含 chainid,此时交易可以在其它链上重放。

当 optimism 基金会向加密货币做市商 Wintermute 授予 2000 千万 op 代币时,目标地址是其在以太坊上合约地址,而此时 L2 网络上的合约还未部署,这便给了黑客可乘之机。

具体过程

Wintermute 在以太坊的目标合约是使用 Proxy Factory 合约生成的,且是采用前面提到的 create操作码生成,这种方式基于部署者地址和 nouce 生成,所以需要首先在 L2 链上生成 Proxy Factory 合约,然后生成目标合约地址

  • 找到 L1 上的交易,在 L2 网络上重放生成指定地址的 Proxy Factory 合约

L1 上 Wintermute 的部署交易

L2 上黑客的重放交易

  • L1 上 Wintermute 使用 Proxy Factory 合约部署目标合约的 nouce 是 57,所以黑客在 L2 上也基于 Proxy Factory 合约在 nouce 为 57 时部署目标合约,于是黑客获得 2000 万 op 的使用所有权

黑客最终部署目标合约的交易[9]

链接

区块链共识安全 - 51%攻击浅析 | 登链社区 | 区块链技术社区[10]

竟然可以推导出私钥?Anyswap 跨链桥被⿊分析

EIP-155: Simple replay attack protection[11]

区块链安全-THE DAO 攻击事件源码分析 - 先知社区[12]

被黑 6.1 亿美金的 Poly Network 事件分析与疑难问答

提案攻击——黑客的新潮流 | 登链社区 | 区块链技术社区[13]

参考资料

[1]

493lab_jhys: https://learnblockchain.cn/people/5228

[2]

EIP155: https://eips.ethereum.org/EIPS/eip-155

[3]

代码: https://etherscan.io/address/0xbb9bc244d798123fde783fcc1c72d3bb8c189413#code

[4]

代币合约: https://bscscan.com/address/0xe1e1aa58983f6b8ee8e4ecd206cea6578f036c21#code

[5]

交易: https://bscscan.com/tx/0x8c93d6e5d6b3ec7478b4195123a696dbc82a3441be090e048fe4b33a242ef09d

[6]

文章: https://learnblockchain.cn/article/4174

[7]

文章: https://learnblockchain.cn/article/4174

[8]

攻击交易: https://etherscan.io/tx/0x3dfa33b5c6150bf3d64f49cb97eba351f99e4dff7119ef458e40f51160bf77ec

[9]

交易: https://optimistic.etherscan.io/tx/0x00a3da68f0f6a69cb067f09c3f7e741a01636cbc27a84c603b468f65271d415b#internal

[10]

区块链共识安全 - 51%攻击浅析 | 登链社区 | 区块链技术社区: https://learnblockchain.cn/2019/01/09/consensus-security-51

[11]

EIP-155: Simple replay attack protection: https://eips.ethereum.org/EIPS/eip-155

[12]

区块链安全-THE DAO攻击事件源码分析 - 先知社区: https://xz.aliyun.com/t/2905

[13]

提案攻击——黑客的新潮流 | 登链社区 | 区块链技术社区: https://learnblockchain.cn/article/4174

0 人点赞