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

上图为Dapp的技术栈,用户的交易请求通过共识网络和区块数据结构驱动状态机的更新;公共的状态空间以及账户模型下的组合性,可以很方便地和最大限度地集合群体智慧,使得Dapp具有无限的可能性。
但任何事物都具有两面性,新的业务形态也带来了复杂的安全形势。Dapp的开发基于密码学、账户模型、公共账本数据库和状态机、通证经济学等,与以前基于中心化数据库和服务器的app,有很大不一样。比如:
不同合约的相互调用带来了可组合性,也带来未知的逻辑,对于一个合约来说,调用其它合约,特别是当被调用的合约地址可以从外部输入时,相当于一个完整逻辑从中间断开,对合约安全的影响很难把握。
一些新工具的诞生,如闪电贷,使得外部调用可能带来的安全问题更具威胁性。
与以前中心化的C/S和B/S应用相比,Dapp的数据库、状态机和业务逻辑代码都是开放的,网上的任何用户几乎都可以获取到Dapp的全部信息,来寻找合约的漏洞。
原理
对dapp来说,既有人为因素和网络钓鱼等传统网络安全问题,又有新的技术和应用场景带来的新的问题,下面主要分析下这些新的问题。
共识层相关
POW的51%攻击
在基于POW共识的区块链系统中,矿工们通过求解密码学难题来竞争新区块的记账权。不同矿工节点间比拼的是算力,谁拥有更高的算力,谁就越有可能可能当前区块的记账权。区块组成链,更长的链代表经历了更多的算力,这就形成了“最长链法则”。
正常情况下,矿工需要基于最长链挖出的区块才会被认可。但是当某个矿工拥有全网一半以上的算力时,他就可以按照自己的需要控制新区块的产出,以及最长链的走向。而这样就可以实现双花了。

下面已具体的例子说明
攻击者控制BitcoinGold网络上51%以上的算力,在控制算力的期间,他把一定数量的BTG发给自己在交易所的钱包,这条分支我们命名为分支A。
同时,他又把这些BTG发给另一个自己控制的钱包,这条分支我们命名为分支B。
分支A上的交易被确认后,攻击者立马卖掉BTG,拿到现金。这时候,分支A成为主链。
然后,攻击者在分支B上进行挖矿,由于其控制了51%以上的算力,那么攻击者获得记账权的概率很大,于是很快分支B的长度就超过了主链(也就是分支A的长度),那么分支B就会成为主链,分支A上的交易就会被回滚,将数据恢复到上一次正确的状态位置。
也就是说,分支A恢复到攻击者发起第一笔交易之前的状态,攻击者之前换成现金的那些BTG又回到了自己手里。
最后,攻击者把这些BTG,发到自己的另一个钱包。就这样,攻击者凭借51%以上的算力控制,实现同一笔token的“双花”。
Tendermint的1/3攻击
在tendermint共识中,需要3f+1的总节点数,而要维持网络的正确运行,恶意节点不能超过f个。从“上帝区块”开始,区块中已约定好后续的生产者名单序列,而后按照顺序生产区块。生产区块时,从propose到commit需要2个阶段:prevote和precommit,且这两个阶段都需要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代币被盗事件就是基于这样的方式。
出块相关
区块链的世界是一维单向的,当不同交易的顺序发生变化时,则状态机的状态变更也会有所不同。交易如何排序是由矿工决定的,这也使得矿工可以获取额外的利益。主要有以下三种获利方式(都是针对的同一区块中的交易):
抢跑,指通过让特定交易排在目标交易前而获利,主要针对清算和套利交易;
尾随,指通过让特定交易排在目标交易后而获利,主要针对预言机交易或大单交易;
三明治夹击,上述两种攻击形式的结合,让目标交易恰好夹在两笔特定构造交易中间,从而获利。三明治攻击大大拓宽了可攻击的范围,哪怕是一笔普通的AMMDEX交易,都有可能成为针对对象。攻击者的第一笔构造交易制造更大的交易价格波动,待目标交易执行完之后紧接着执行第二笔构造交易,换回发动攻击的代币完成获益。
交易排序问题进一步导致了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
可以看到,除了运算逻辑、存储逻辑、流程控制逻辑等常规的指令外,还有像交易状态信息读取、合约代码、创建和调用、自毁等独特的操作指令。这些特殊的指令的使用也带来新的风险。
重入
每个合约地址都有自己的代码,代表一个业务处理逻辑,不同的合约可以通过外部调用进行组合,创造更复杂的应用。但在进行外部调用的时候,也会把程序执行的控制权暂时转移到其它合约上,这会导致原本自身完整的逻辑被破坏,容易出现意想不到的情况。
比如某些合约可以进行质押和提款操作,提款时可能会产生重入问题,下面是一个例子:
functionwithdrawBalance(){
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交易的市场合约中,可能有下面代码:
functionbatch(bytes[]calldatacalls,boolrevertOnFail)externalpayablereturns(bool[]memorysuccesses,bytes[]memoryresults){
successes=newbool[](calls.length"");
results=newbytes[](calls.length"");
for(uint256i=0;i<calls.length;i++){
(boolsuccess,bytesmemoryresult)=address(this).delegatecall(calls[i]);
require(success||!revertOnFail,_getReyerMsg(result));
successes[i]=success;
results[i]=result;
}
}

可以看到,若调用此合约进行nft的批量购买,则msg.value可以重复使用。
随机数问题
在一些gamefi合约中,需要使用随机数来完成一些功能,而这些随机数的种子来源可能是一些区块的状态变量加上用户的一些输入,比如下面的代码:
functionrand(address_to,uint256tokenId)publicviewreturns(uint256){
uint256random=uint256(
keccak256(
abi.encodePacked(
block.difficulty,
block.timestamp,
_to,
tokenId,
block.number
)
)
);
returnrandom%1000;
}

若此合约中一些与资产操作有关的方法基于rand方法时,用户可通过部署合约来提前得到随机数的值从而规避不利的随机数。
交易的原子性问题
区域区块链的每一笔交易,要么成功,要么失败。失败的话,所做的状态变更都会还原。在gamefi场景中,也可以得到利用。同样是上面的随机数场景,我们可以合约来进行相关操作,当最终结果不利时,可以让交易无效,来挽回损失。
SELFDESTRUCT操作码
正常情况下,合约若要默认可接收eth转账,则需提供receive或者fallback方法,但需注意SELFDESTRUCT可强制转账到某合约,而不需要这两个方法。
主网代币与合约代币的区别
主网代币是记录在每个账户下的一个变量,可用于支付gas;而合约代币是合约地址下的一个数据记录。两者的转账操作在处理上是不一样,在涉及到其操作的合约里,一定要注意区别处理。
合约地址与EOA地址的调用区别
调用合约时,需要合约有对应的方法,否则会报错;而非合约地址则没有这样的要求,只要余额和gas足够就行。在校验外部调用是否成功时,需要考虑这种情况。QBridge安全事件就是基于此的。
链上难以有效判断一个地址为非合约
一个合约地址的CODESIZE是大于零的,但当地址的CODESIZE等于零时,并不能保证其为非合约,因为合约在构造阶段CODESIZE也为零。
dapp安全事件
defi场景
xsurge攻击事件
xsurge是bsc上的defi协议,其代币合约[4]中提供了sell和purchase方法用于使用BNB买卖其代币surge,但是其合约中存在价格计算缺陷和重入漏洞。图片

可以看到,在sell方法中先转账,然后修改状态,而在转完BNB而surge余额未减去时,两者的兑换价格发生了突变,且由于BNB减少surge不变,一个BNB可以买更多的surge。虽然sell方法中有重入控制,但purchase没有,重入控制只能阻止再次进入sell方法,但依旧可以进入purchase方法中进行购买操作。
黑客便是利用这个漏洞循环在交易[5]中循环进行买卖操作,每循环一次就能获取更多的BNB

DAO场景
BeanstalkFarms安全事件
在这次攻击事件中,攻击者创建了一个恶意提案,通过闪电贷获得了足够多的投票,并执行了该提案,从而从协议中窃取了资产,总共获利差不多8000万美金。详细的过程见之前写的文章[6]
FortressLoans安全事件
FortressLoans协议是一个借贷协议,且通过DAO治理,FTS是其治理代币,该协议在代码层面和经济层面都存在一些问题。
对FTS的价格获取存在漏洞,可以被任意修改,这对借贷协议来说是致命的
协议治理中,执行提案的FTS要求仅为总量的4%,且价格低,兑换成本仅为11ETH
于是,黑客提交恶意提案,将FTS加入担保资产,并控制其价格,得以从协议中借出远超其担保物真实价值的资产,获利离场。详细的过程见之前写的文章[7]
跨链场景
poly网络攻击事件
Polynetwork是一个跨链网络,在这次事件被盗6.1亿美元
跨链原理

上图介绍了从A链跨链到链B的详细流程,用户在链A发起跨链请求,调用了DApp的跨链接口,最终会在B链的DApp合约得到用户想要的结果。A链和B链实现了上文的两本合约及其接口,任何人都可以围绕跨链管理合约建立稳定可用的跨链DApp,分别在A链和B链部署业务合约,这些合约会组成一个完整的跨链DApp
用户调用A链的业务合约,合约会进一步调用跨链管理合约,传递用户的跨链参数,跨链管理合约会创建跨链交易,随着A链出块,交易落账;
由于链与链之间是不会主动交换信息的,所以需要一个Relayer去传递信息,Relayer会把A链的区块头同步到中继链的区块头同步合约,然后从A链的存储中取出跨链管理合约返回的事件,其中包含用户的跨链参数,再获取跨链交易的MerkleProof,一并转发给中继链的跨链管理合约;
中继链的跨链管理合约会读取A链的区块头,验证跨链参数的Proof是否正确,验证通过后,会将B链需要的跨链信息以事件的形式返回;
B链的Relayer会将中继链区块头同步到B链的区块头同步合约,然后从中继链的账本中获取到B链的跨链参数和其MerkleProof,提交到B链的跨链管理合约;
链B的跨链管理合约验证跨链信息的正确性,然后调用信息里的目标合约,完成跨链合约的调用;
上面的流程中,共有两个MerkleProof,第一个证明了来自A链跨链信息确实存在于A链,第二个则证明了跨链信息确实存在于中继链,如此便建立了跨链的信任机制。这就是跨链DApp的运行流程,所有的侧链仅需和中继链生态交互即可。
潜在问题
同一条链上的转账交易具有原子性,但当需要跨链时,其原子性被打破了,转入和转出发生在不同的链上。当然这样说其实并不太恰当,转入与转出在各自的链上都是一笔完整的转账操作,只是通过由各自链上合约进行资金托管的方式进行隐藏。
对两个特定链的跨链来说,各自的合约需要实现对方的签名验证,再加上第三方同步两条链的区块与交易。此过程对于签名验证来说,并没有引入额外的风险,也就是说贯穿始终的还是发起方的交易签名。
上述的两链之间的直接跨链实际使用很受限。为了实现跨链的通用性,需要引入一条专门的链,其它的链都之和它进行跨链。这样的话,跨链转账交易的流程更长了,而且更为重要的是引入了额外的风险。即中间链的担保效应,源链的转入证明不再由目标链上的合约直接验证,而是由中间链验证,再由中间链进行担保,目标链上的合约对担保进行验证。此次poly攻击也是从这里入手的。
此次攻击的关键点
前面提到,使用中继链后,资金的安全实际上依赖于中继链的多个验证人(也就是keepers)。正常情况下,这不会有什么问题,中继链会验证源链的签名,目标链验证中继链keepers的签名,用户只能使用自己的资金。但由于合约存在缺陷,使得keepers被修改,黑客可以使用协议中的所以资金。

从上图我们可以看出_executeCrossChainTx函数未对传入的_toContract、_method等参数进行检查就直接以_toContract.call的方式执行交易。通过hash碰撞构造特定的方法签名,则可以以管理合约的身份执行一些特殊的方法。而该管理合约也正好提供了putCurEpochConPubKeyBytes函数可以直接修改keepers公钥。关于此处攻击的细节见慢雾的分析。
QBridge安全事件
在这次事件中,黑客获利8000万美元。
QBridge是一个跨链协议,但其合约存在两个缺陷:一个是在跨链deposit时,对主网代币和erc20代币虽然提供了不同的方法,但并未做严格的限制;另一个是在做转账调用时,并未考虑合约地址和EOA地址的区别。
QBridge合约
functiondeposit(uint8destinationDomainID,bytes32resourceID,bytescalldatadata)externalpayablenotPaused{
require(msg.value==fee,"QBridge:invalidfee");
addresshandler=resourceIDToHandlerAddress[resourceID];
require(handler!=address(0),"QBridge:invalidresourceID");
uint64depositNonce=++_depositCounts[destinationDomainID];
IQBridgeHandler(handler).deposit(resourceID,msg.sender,data);
emitDeposit(destinationDomainID,resourceID,depositNonce,msg.sender,data);
}
functiondepositETH(uint8destinationDomainID,bytes32resourceID,bytescalldatadata)externalpayablenotPaused{
uintoption;
uintamount;
(option,amount)=abi.decode(data,(uint,uint));
require(msg.value==amount.add(fee),"QBridge:invalidfee");
addresshandler=resourceIDToHandlerAddress[resourceID];
require(handler!=address(0),"QBridge:invalidresourceID");
uint64depositNonce=++_depositCounts[destinationDomainID];
IQBridgeHandler(handler).depositETH{value:amount}(resourceID,msg.sender,data);
emitDeposit(destinationDomainID,resourceID,depositNonce,msg.sender,data);
}
handler合约
functiondeposit(bytes32resourceID,addressdepositer,bytescalldatadata)externaloverrideonlyBridge{
uintoption;
uintamount;
(option,amount)=abi.decode(data,(uint,uint));
addresstokenAddress=resourceIDToTokenContractAddress[resourceID];
require(contractWhitelist[tokenAddress],"providedtokenAddressisnotwhitelisted");
if(burnList[tokenAddress]){
require(amount>=withdrawalFees[resourceID],"lessthanwithdrawalfee");
QBridgeToken(tokenAddress).burnFrom(depositer,amount);
}else{
require(amount>=minAmounts[resourceID][option],"lessthanminimumamount");
tokenAddress.safeTransferFrom(depositer,address(this),amount);
}
}
functiondepositETH(bytes32resourceID,addressdepositer,bytescalldatadata)externalpayableoverrideonlyBridge{
uintoption;
uintamount;
(option,amount)=abi.decode(data,(uint,uint));
require(amount==msg.value);
addresstokenAddress=resourceIDToTokenContractAddress[resourceID];
require(contractWhitelist[tokenAddress],"providedtokenAddressisnotwhitelisted");
require(amount>=minAmounts[resourceID][option],"lessthanminimumamount");
}
攻击交易[8]
攻击者指定传入的resourceID为跨ETH代币所需要的值,但其调用的是QBridge的deposit函数而非depositETH函数
handler合约的deposit函数中会根据resourceID取出的所要充值的代币,而ETH对应的所要充值的代币为0地址
由于所要充值的代币地址为0地址,而call调用无codesize的EOA地址时其执行结果都会为true且返回值为空,因此通过了safeTransferFrom的检查,最后触发了Deposit跨链充值事件
Optimism安全事件
此次事件,黑客获利2000万op代币
前面的“重放攻击”章节中提到,对于evm生态来说,当一笔交易签名的v值为27或28时,则签名信息中不包含chainid,此时交易可以在其它链上重放。
当optimism基金会向加密货币做市商Wintermute授予2000千万op代币时,目标地址是其在以太坊上合约地址,而此时L2网络上的合约还未部署,这便给了黑客可乘之机。
具体过程
Wintermute在以太坊的目标合约是使用ProxyFactory合约生成的,且是采用前面提到的create操作码生成,这种方式基于部署者地址和nouce生成,所以需要首先在L2链上生成ProxyFactory合约,然后生成目标合约地址
找到L1上的交易,在L2网络上重放生成指定地址的ProxyFactory合约
L1上Wintermute的部署交易

L2上黑客的重放交易

L1上Wintermute使用ProxyFactory合约部署目标合约的nouce是57,所以黑客在L2上也基于ProxyFactory合约在nouce为57时部署目标合约,于是黑客获得2000万op的使用所有权
黑客最终部署目标合约的交易[9]

链接
区块链共识安全-51%攻击浅析|登链社区|区块链技术社区[10]
竟然可以推导出私钥?Anyswap跨链桥被⿊分析
EIP-155:Simplereplayattackprotection[11]
区块链安全-THEDAO攻击事件源码分析-先知社区[12]
被黑6.1亿美金的PolyNetwork事件分析与疑难问答
提案攻击——黑客的新潮流|登链社区|区块链技术社区[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:Simplereplayattackprotection:https://eips.ethereum.org/EIPS/eip-155
[12]
区块链安全-THEDAO攻击事件源码分析-先知社区:https://xz.aliyun.com/t/2905
[13]
提案攻击——黑客的新潮流|登链社区|区块链技术社区:https://learnblockchain.cn/article/4174





