本文作者:493lab_jhys[1]
以太坊以及 EVM 的诞生使得 Dapp
这种新的业务形态成为可能。总的来说,EVM 实现了一个全局的状态机,为所有的 Dapp
提供了统一的状态空间;实现了图灵完备,并抽象出了账户模型,账户之间可以相互调用,使得不同的应用可以无缝组合,展现了 Dapp
的独特魅力。
上图为 Dapp
的技术栈,用户的交易请求通过共识网络和区块数据结构驱动状态机的更新;公共的状态空间以及账户模型下的组合性,可以很方便地和最大限度地集合群体智慧,使得 Dapp
具有无限的可能性。
但任何事物都具有两面性,新的业务形态也带来了复杂的安全形势。Dapp
的开发基于密码学、账户模型、公共账本数据库和状态机、通证经济学等,与以前基于中心化数据库和服务器的 app
,有很大不一样。比如:
C/S
和 B/S
应用相比,Dapp
的数据库、状态机和业务逻辑代码都是开放的,网上的任何用户几乎都可以获取到 Dapp
的全部信息,来寻找合约的漏洞。对 dapp
来说,既有人为因素和网络钓鱼等传统网络安全问题,又有新的技术和应用场景带来的新的问题,下面主要分析下这些新的问题。
在基于 POW
共识的区块链系统中,矿工们通过求解密码学难题来竞争新区块的记账权。不同矿工节点间比拼的是算力,谁拥有更高的算力,谁就越有可能可能当前区块的记账权。区块组成链,更长的链代表经历了更多的算力,这就形成了“最长链法则”。
正常情况下,矿工需要基于最长链挖出的区块才会被认可。但是当某个矿工拥有全网一半以上的算力时,他就可以按照自己的需要控制新区块的产出,以及最长链的走向。而这样就可以实现双花了。
下面已具体的例子说明
在 tendermint
共识中,需要 3f+1
的总节点数,而要维持网络的正确运行,恶意节点不能超过 f
个。从“上帝区块”开始,区块中已约定好后续的生产者名单序列,而后按照顺序生产区块。生产区块时,从 propose
到 commit
需要 2
个阶段:prevote
和 precommit
,且这两个阶段都需要 2/3
以上的节点签名。下图为生产区块的流程图
当有 f+1
个恶意节点时,便可以分别向余下的两 f
节点分别发送不同的区块,从而使网络分叉,实现双花。
使用钱包和区块链交互时,需要用保存在本地的私钥对消息进行签名,然后发给节点。其签名过程如下:
anyswap 便发生过这样的安全事件,见文末的链接
使用 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 代币被盗事件就是基于这样的方式。
区块链的世界是一维单向的,当不同交易的顺序发生变化时,则状态机的状态变更也会有所不同。交易如何排序是由矿工决定的,这也使得矿工可以获取额外的利益。主要有以下三种获利方式(都是针对的同一区块中的交易):
交易排序问题进一步导致了 MEV(矿工可提取手续)问题,也是区块链发展的一个重要研究方向。
操作码分类
可以看到,除了运算逻辑、存储逻辑、流程控制逻辑等常规的指令外,还有像交易状态信息读取、合约代码、创建和调用、自毁等独特的操作指令。这些特殊的指令的使用也带来新的风险。
每个合约地址都有自己的代码,代表一个业务处理逻辑,不同的合约可以通过外部调用进行组合,创造更复杂的应用。但在进行外部调用的时候,也会把程序执行的控制权暂时转移到其它合约上,这会导致原本自身完整的逻辑被破坏,容易出现意想不到的情况。
比如某些合约可以进行质押和提款操作,提款时可能会产生重入问题,下面是一个例子:
function withdrawBalance() {
amountToWithdraw = userBalances[msg.sender];
if (!(msg.sender.call.value(amountToWithdraw)())) { throw; }
userBalances[msg.sender] = 0;
}
正常情况下,转账操作和修改余额的操作应该绑定在一起,具有原子性。但由于使用 call
转账时,程序执行被转移到新的地址上了,原本逻辑的原子性受到破坏。导致转账发生了但余额未减,且此过程可以不断进行,最终把不属于自己的余额也转走了。
导致原来的 ETC 回滚硬分叉产生现在的 ETH 的 theDAO 事件就是一起典型的利用重入的安全事件,当然其真实的代码[3]要复杂些,但原理是一样的。
在委托调用中,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 场景中,也可以得到利用。同样是上面的随机数场景,我们可以合约来进行相关操作,当最终结果不利时,可以让交易无效,来挽回损失。
正常情况下,合约若要默认可接收 eth
转账,则需提供 receive
或者 fallback
方法,但需注意 SELFDESTRUCT
可强制转账到某合约,而不需要这两个方法。
主网代币是记录在每个账户下的一个变量,可用于支付 gas;而合约代币是合约地址下的一个数据记录。两者的转账操作在处理上是不一样,在涉及到其操作的合约里,一定要注意区别处理。
调用合约时,需要合约有对应的方法,否则会报错;而非合约地址则没有这样的要求,只要余额和 gas 足够就行。在校验外部调用是否成功时,需要考虑这种情况。QBridge 安全事件就是基于此的。
一个合约地址的 CODESIZE
是大于零的,但当地址的 CODESIZE
等于零时,并不能保证其为非合约,因为合约在构造阶段 CODESIZE
也为零。
xsurge 是 bsc 上的 defi 协议,其代币合约[4]中提供了 sell
和 purchase
方法用于使用 BNB
买卖其代币 surge
,但是其合约中存在价格计算缺陷和重入漏洞。
可以看到,在 sell
方法中先转账,然后修改状态,而在转完 BNB 而 surge 余额未减去时,两者的兑换价格发生了突变,且由于 BNB 减少 surge 不变,一个 BNB 可以买更多的 surge。虽然 sell
方法中有重入控制,但 purchase
没有,重入控制只能阻止再次进入 sell
方法,但依旧可以进入 purchase
方法中进行购买操作。
黑客便是利用这个漏洞循环在交易[5]中循环进行买卖操作,每循环一次就能获取更多的 BNB
在这次攻击事件中,攻击者创建了一个恶意提案,通过闪电贷获得了足够多的投票,并执行了该提案,从而从协议中窃取了资产,总共获利差不多 8000 万美金。详细的过程见之前写的文章[6]
Fortress Loans 协议是一个借贷协议,且通过 DAO
治理,FTS
是其治理代币,该协议在代码层面和经济层面都存在一些问题。
于是,黑客提交恶意提案,将 FTS 加入担保资产,并控制其价格,得以从协议中借出远超其担保物真实价值的资产,获利离场。详细的过程见之前写的文章[7]
Poly network 是一个跨链网络,在这次事件被盗 6.1 亿美元
跨链原理
上图介绍了从 A 链跨链到链 B 的详细流程,用户在链 A 发起跨链请求,调用了 DApp 的跨链接口,最终会在 B 链的 DApp 合约得到用户想要的结果。A 链和 B 链实现了上文的两本合约及其接口,任何人都可以围绕跨链管理合约建立稳定可用的跨链 DApp,分别在 A 链和 B 链部署业务合约,这些合约会组成一个完整的跨链 DApp
上面的流程中,共有两个 Merkle Proof,第一个证明了来自 A 链跨链信息确实存在于 A 链,第二个则证明了跨链信息确实存在于中继链,如此便建立了跨链的信任机制。这就是跨链 DApp 的运行流程,所有的侧链仅需和中继链生态交互即可。
潜在问题
同一条链上的转账交易具有原子性,但当需要跨链时,其原子性被打破了,转入和转出发生在不同的链上。当然这样说其实并不太恰当,转入与转出在各自的链上都是一笔完整的转账操作,只是通过由各自链上合约进行资金托管的方式进行隐藏。
对两个特定链的跨链来说,各自的合约需要实现对方的签名验证,再加上第三方同步两条链的区块与交易。此过程对于签名验证来说,并没有引入额外的风险,也就是说贯穿始终的还是发起方的交易签名。
上述的两链之间的直接跨链实际使用很受限。为了实现跨链的通用性,需要引入一条专门的链,其它的链都之和它进行跨链。这样的话,跨链转账交易的流程更长了,而且更为重要的是引入了 额外的风险 。即中间链的担保效应,源链的转入证明不再由目标链上的合约直接验证,而是由中间链验证,再由中间链进行担保,目标链上的合约对担保进行验证。此次 poly 攻击也是从这里入手的。
此次攻击的关键点
前面提到,使用中继链后,资金的安全实际上依赖于中继链的多个验证人(也就是 keepers
)。正常情况下,这不会有什么问题,中继链会验证源链的签名,目标链验证中继链 keepers
的签名,用户只能使用自己的资金。但由于合约存在缺陷,使得 keepers
被修改,黑客可以使用协议中的所以资金。
从上图我们可以看出 _executeCrossChainTx 函数未对传入的 _toContract、_method 等参数进行检查就直接以 _toContract.call 的方式执行交易。通过hash 碰撞构造特定的方法签名,则可以以管理合约的身份执行一些特殊的方法。而该管理合约也正好提供了 putCurEpochConPubKeyBytes 函数可以直接修改 keepers
公钥。关于此处攻击的细节见慢雾的分析。
在这次事件中,黑客获利 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");
}
攻击交易[8]
此次事件,黑客获利 2000 万 op 代币
前面的“重放攻击”章节中提到,对于 evm 生态来说,当一笔交易签名的 v 值为 27 或 28 时,则签名信息中不包含 chainid,此时交易可以在其它链上重放。
当 optimism 基金会向加密货币做市商 Wintermute 授予 2000 千万 op 代币时,目标地址是其在以太坊上合约地址,而此时 L2 网络上的合约还未部署,这便给了黑客可乘之机。
具体过程
Wintermute 在以太坊的目标合约是使用 Proxy Factory 合约生成的,且是采用前面提到的 create
操作码生成,这种方式基于部署者地址和 nouce 生成,所以需要首先在 L2 链上生成 Proxy Factory 合约,然后生成目标合约地址
L1 上 Wintermute 的部署交易
L2 上黑客的重放交易
黑客最终部署目标合约的交易[9]
区块链共识安全 - 51%攻击浅析 | 登链社区 | 区块链技术社区[10]
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