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

  • jianghai
  • 更新于 2022-06-15 20:37
  • 阅读 4583

以太坊以及EVM的诞生使得 Dapp这种新的业务形态成为可能。总的来说,EVM实现了一个全局的状态机,为所有的 Dapp提供了统一的状态空间;实现了图灵完备,并抽象出了账户模型,账户之间可以相...

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

1.svg

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

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

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

原理

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

共识层相关

POW的51%攻击

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

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

2.png

下面已具体的例子说明

  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以上的节点签名。下图为生产区块的流程图 3.png

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

密码学相关

私钥恢复

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

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

hash碰撞

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

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

重放攻击

根据EIP155,对交易进行签名,有两种形式:一是 (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

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

重入

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

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

function withdrawBalance() {  
  amountToWithdraw = userBalances[msg.sender];
  if (!(msg.sender.call.value(amountToWithdraw)())) { throw; }
  userBalances[msg.sender] = 0;
}

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

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

msg.value的持久化问题

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

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

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协议,其代币合约中提供了 sellpurchase方法用于使用 BNB买卖其代币 surge,但是其合约中存在价格计算缺陷和重入漏洞。 4.png

5.png

6.png

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

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

DAO场景

Beanstalk Farms安全事件

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

Fortress Loans安全事件

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

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

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

跨链场景

poly网络攻击事件

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

跨链原理

8.png

上图介绍了从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被修改,黑客可以使用协议中的所以资金。 9.png

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

QBridge安全事件

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

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

QBridge合约

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合约

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");
    }

攻击交易

  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的部署交易 10.png

L2上黑客的重放交易

11.png

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

黑客最终部署目标合约的交易 12.png

链接

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

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

EIP-155: Simple replay attack protection

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

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

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

$$

$$

点赞 5
收藏 9
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

1 条评论

请先 登录 后评论
jianghai
jianghai
江湖只有他的大名,没有他的介绍。