概述
Web3 发展至今出现了很多不同的链,从最开始的 Bitcoin,再到后续的 Ethereum 还有各种 Ethereum L2,还有 Solana,Sei 等链层出不穷,但是各个链如同一个独立的空间,用户在各个链上的资产是无法共享的,如果用户想在 Ethereum 上使用在 Solana 上的 USDC 资产是不行的,所以跨链桥出现了,跨链桥顾名思义,像桥一样两端连接不同的链,可以帮助用户在各个链上转移资产。
跨链桥的出现增加了链之间的可交互性,但也引入了新的攻击向量,同时由于跨链桥上承载的资金量较大,很容易因为安全问题导致巨大的资金损失,根据 defillama 上的数据,至今关于跨链桥的攻击事件导致损失已经来到 $2.87 billion,接下来本文将介绍几个跨链桥相关的安全问题。
私钥管理相关
2024.04 – Ronin Bridge
攻击总览
攻击者疑似为 Lazarus Group
攻击共计造成损失 $540m
背景知识
Ronin Network 是 Ethereum 的侧链,所谓侧链即为单独的一个区块链网络,侧链和 Ethereum 通过一个跨链桥来连接,侧链有自己的验证节点,单独的共识算法等,所以这里类似于通过跨链桥将两个独立的网络连接起来。
Ronin Network 在当时还处在比较早期的阶段,存在 9 个验证节点,而 Ronin Network 的跨链桥是基于验证者的跨链桥,意味着从 Ronin Network 的跨链桥上转移资金需要 5 个验证节点验证即可,这 9 个验证节点中,有 5 个是由 Axie DAO 控制,另外 4 个由 Sky Mavis 控制,因为在 2021 年 11 月的时候,Sky Mavis 为了更快的交易处理时间向 Axie DAO 寻求由自己运行一部分节点的允许,但是同年 12 月份时 Sky Mavis 的项目不再继续,由于操作疏忽导致 Sky Mavis 的验证节点并未从 allow list 中删除,所以那些验证节点仍然可以用来验证交易。
黑客通过社会工程学攻击了 Sky Mavis,获得了他们的 4 台验证节点,同时攻击了 Axie DAO 的 1 台验证节点,从而集齐了 5 个验证节点来批准恶意的提款交易,获得了可以用于提款的签名,从而能够将 Ronin Network 的跨链桥资金转移走。
这是攻击者其中一笔提取资金的交易。这个例子反映出基于验证者的跨链桥需要保证链下基础设施的安全性以及验证节点私钥管理的安全性,比如在这个例子中 Sky Mavis 单个实体拥有接近整个网络一半的验证节点,过于中心化导致该系统的安全性大大降低,并且被社会工程学攻击的验证节点也反映出员工安全意识培训的不足,这提醒了跨链桥开发者要做好这两个方面的防护,比如针对员工要做好社会工程学攻击的防范培训,采取妥善的权限控制,并且采用成熟的密钥管理方案,并且如今也有新兴的供应链攻击,需要保证在开发过程中不会出现被依赖库恶意投毒导致基础设备被攻破。
此事件中,攻击者转移资金6天后才被发现,一个参与者在提取 ETH 时发现无法提取,由此项目方才发现跨链桥被攻击,在此事件中项目方的响应过于滞后,这里提醒开发者需要完善的实时监控程序,保证紧急事件的及时响应,如果在这里项目方可以及时发现验证节点的异常,可以直接暂停跨链桥的功能,这样可以有效阻止本次攻击的发生。
代码漏洞相关
2024.10 – Binance Bridge
攻击总览
攻击者身份未知
攻击共计造成损失 $570m
攻击分析 - 背景知识
默克尔树
默克尔树是一个树状的数据结构,在区块链领域默克尔树常被用于带有隐私性的验证数据,基于一段数据进行默克尔树的构建,首先数据被分成 n 部分,每部分进行哈希后得到默克尔树的叶子节点,每个叶子节点再连接在一起进行哈希得到下一层节点,逐层哈希最后得到默克尔树的根节点,作为可信根。
验证时,只要接受其中一个叶子节点的值,按照构建默克尔树的过程进行运算,最终得到的可信根和最开始构建默克尔树得出的可信根进行比较,即可验证数据。
举一个例子,比如项目方要进行空投,将有资格获得空投的地址和数量作为数据,组成默克尔树运算出可信根后存储在获取空投的合约中,按照上述的方式进行验证,以上述图作为例子,用户提供自己数据的哈希 Hash(B),还有 proofs 数组[Hash(A), Hash(HCHD)],验证过程中先对Hash(B)和 proof 数组的第一个元素Hash(A) 连接进行哈希,得到哈希值再和 proof 数组第二个元素Hash(HCHD) 进行哈希,得到可信根,和合约中存储的可信根进行对比,验证通过后就向对应地址发送空投代币。
BSC Token Hub
BSC Token Hub 是作为跨链桥的一个负责转移代币的模块,入口是 BSC 链上的一个 CrossChain 合约,CrossChain 合约通过handlePackage 函数来根据参数的不同去调用不同的功能,在handlePackage 函数的入口会进行默克尔树的验证,而默克尔树的验证最终会来到 BSC 链实现的 precompile 合约。
// CrossChain contract:
functionhandlePackage(
bytes calldata payload,
bytes calldata proof,
uint64 height,
uint64 packageSequence,
uint8 channelId
)
external
onlyInit
onlyRelayer
sequenceInOrder(packageSequence, channelId)
blockSynced(height)
channelSupported(channelId)
headerInOrder(height, channelId)
whenNotSuspended
{
bytes memory payloadLocal = payload; // fix error: stack too deep, try removing local variables
bytes memory proofLocal = proof; // fix error: stack too deep, try removing local variables
require(
MerkleProof.validateMerkleProof(
ILightClient(LIGHT_CLIENT_ADDR).getAppHash(height),
STORE_NAME,
generateKey(packageSequence, channelId),
payloadLocal,
proofLocal
),
"invalid merkle proof"
);
...
}
// MerkleProof:
library MerkleProof {
functionvalidateMerkleProof(
bytes32 appHash,
string memory storeName,
bytes memory key,
bytes memory value,
bytes memory proof
) internal view returns(bool) {
...
/* solium-disable-next-line */
assembly {
// call validateMerkleProof precompile contract
// Contract address: 0x65
ifiszero(staticcall(not(0), 0x65, input, length, result, 0x20)){ }
}
/// check the proof is valid or not.
return result[0] == 0x01;
}
}
向右滑动查看详情
0x65 地址 是 BSC 链代码注册的 precompile 合约,这是 BSC 链的部分代码
// PrecompiledContractsHertz contains the default set of pre-compiled Ethereum
// contracts used in the Hertz release.
var PrecompiledContractsHertz = PrecompiledContracts{
common.BytesToAddress([]byte{0x1}): &ecrecover{},
common.BytesToAddress([]byte{0x2}): &sha256hash{},
common.BytesToAddress([]byte{0x3}): &ripemd160hash{},
common.BytesToAddress([]byte{0x4}): &dataCopy{},
common.BytesToAddress([]byte{0x5}): &bigModExp{eip2565: true},
common.BytesToAddress([]byte{0x6}): &bn256AddIstanbul{},
common.BytesToAddress([]byte{0x7}): &bn256ScalarMulIstanbul{},
common.BytesToAddress([]byte{0x8}): &bn256PairingIstanbul{},
common.BytesToAddress([]byte{0x9}): &blake2F{},
common.BytesToAddress([]byte{0x64}): &tmHeaderValidate{},
common.BytesToAddress([]byte{0x65}): &iavlMerkleProofValidatePlato{}, // Here
common.BytesToAddress([]byte{0x66}): &blsSignatureVerify{},
common.BytesToAddress([]byte{0x67}): &cometBFTLightBlockValidateHertz{},
}
向右滑动查看详情
可以看到 0x65 地址对应的是iavlMerkleProofValidatePlato
BSC 链使用的是 cosmos 开发的 IAVL 库来进行默克尔树验证,iavl 展开是 Immutabl AVL,是一个自平衡二叉搜索树,提高了操作效率,而该 IAVL 库的逻辑实现有问题导致了本次攻击的发生。
在 IAVL 默克尔树中,每个节点有 Left 和 Right 两种属性,如此设计是为了标识在验证过程中该节点是作为左边节点还是右边节点来进行连接后哈希,在 IAVL 库中计算 root hash 的代码。
func(proof *RangeProof) _computeRootHash() (rootHash []byte, treeEnd bool, err error) {
iflen(proof.Leaves)== 0 {
return nil, false, cmn.ErrorWrap(ErrInvalidProof, "no leaves")
}
if len(proof.InnerNodes)+1 != len(proof.Leaves) {
return nil, false, cmn.ErrorWrap(ErrInvalidProof, "InnerNodes vs Leaves length mismatch, leaves should be 1 more.")
}
// Start from the left path and prove each leaf.
// shared across recursive calls
var leaves = proof.Leaves
var innersq = proof.InnerNodes
var COMPUTEHASH func(path PathToLeaf, rightmost bool) (hash []byte, treeEnd bool, done bool, err error)
// rightmost: is the root a rightmost child of the tree?
// treeEnd: true iff the last leaf is the last item of the tree.
// Returns the (possibly intermediate, possibly root) hash.
COMPUTEHASH = func(path PathToLeaf, rightmost bool) (hash []byte, treeEnd bool, done bool, err error) {
// Pop next leaf.
nleaf, rleaves := leaves[0], leaves[1:]
leaves = rleaves
// Compute hash.
hash = (pathWithLeaf{
Path: path,
Leaf: nleaf,
}).computeRootHash()
// If we don't have any leaves left, we're done.
if len(leaves) == 0 {
rightmost = rightmost && path.isRightmost()
return hash, rightmost, true, nil
}
// Prove along path (until we run out of leaves).
for len(path) > 0 {
// Drop the leaf-most (last-most) inner nodes from path
// until we encounter one with a left hash.
// We assume that the left side is already verified.
// rpath: rest of path
// lpath: last path item
rpath, lpath := path[:len(path)-1], path[len(path)-1]
path = rpath
if len(lpath.Right) == 0 {
continue
}
// Pop next inners, a PathToLeaf (e.g. []proofInnerNode).
inners, rinnersq := innersq[0], innersq[1:]
innersq = rinnersq
// Recursively verify inners against remaining leaves.
derivedRoot, treeEnd, done, err := COMPUTEHASH(inners, rightmost && rpath.isRightmost())
if err != nil {
return nil, treeEnd, false, cmn.ErrorWrap(err, "recursive COMPUTEHASH call")
}
if !bytes.Equal(derivedRoot, lpath.Right) {
return nil, treeEnd, false, cmn.ErrorWrap(ErrInvalidRoot, "intermediate root hash %X doesn't match, got %X", lpath.Right, derivedRoot)
}
if done {
return hash, treeEnd, true, nil
}
}
// We're not done yet (leaves left over). No error, not done either.
// Technically if rightmost, we know there's an error "left over leaves
// -- malformed proof", but we return that at the top level, below.
return hash, false, false, nil
}
// Verify!
path := proof.LeftPath
rootHash, treeEnd, done, err := COMPUTEHASH(path, true)
if err != nil {
return nil, treeEnd, cmn.ErrorWrap(err, "root COMPUTEHASH call")
} elseif !done {
return nil, treeEnd, cmn.ErrorWrap(ErrInvalidProof, "left over leaves -- malformed proof")
}
// Ok!
return rootHash, treeEnd, nil
}
向右滑动查看详情
IAVL 支持多个叶子节点进行同时验证,在这个过程中,会首先计算第一个叶子节点的整个路径的正确性,后续的叶子节点只需要验证子路径是否正确即可,需要注意的是代码中的这一句注释
// We assume that the left side is already verified.
这是在验证了第一个叶子节点正确性后,对后续的叶子节点进行验证的代码注释,这里 IAVL 已经假定了 proof 的正确性,而显然 BSC 在使用这段代码的时候没有验证 proof 中的正确性,由于 IAVL 引入了 Left 和 Right 两个属性,但是在每次验证的 proof 中,每个节点的 Left 和 Right 属性是确定的,所以正确的节点一定是只存在一个属性,但是如果构建出两个属性都存在的节点呢?
基于上述的验证过程,攻击者需要使用之前合法的一个验证,并且保证正确的叶子节点的下一层运算路径的节点具有 Left 属性,然后在其中添加一个恶意的叶子节点,恶意的叶子节点是攻击者准备进行的恶意行为,这里是铸造大量的 BNB,添加了恶意叶子节点后,要将那个具有 Left 属性的节点添加 Right 属性,该属性是保证在运算的过程能通过验证,在第一个正确的叶子节点通过验证后,在对恶意叶子节点验证的过程中只是确保了它的位置正确性,并没有对最后的运算结果进行校验,所以这里通过添加 Right 属性来通过位置的正确性验证,下图是攻击者对合法的 proof 进行的更改,注入了恶意的铸造 BNB payload。
[1]: https://www.quillaudits.com/blog/hack-analysis/bsc-token-hub-bridge-hack
而在事后 BSC 的修复代码对节点的正确性进行了验证,保证整个验证过程不会出现上述情况
funcsingleValueOpVerifier(op merkle.ProofOperator) error {
if op == nil {
return nil
}
if valueOp, ok := op.(iavl.IAVLValueOp); ok {
iflen(valueOp.Proof.Leaves) != 1 {
return cmn.NewError("range proof suspended")
}
for _, innerNode := range valueOp.Proof.LeftPath {
if len(innerNode.Right) > 0 && len(innerNode.Left) > 0 {
return cmn.NewError("both right and left hash exit!")
}
}
}
return nil
}
向右滑动查看详情
合约更新相关
2024.08 – Ronin Bridge
攻击总览
攻击者的攻击因为 gas 费问题失败,被 mev 机器人抢跑
攻击共计造成损失 $12m,mev 机器人操作者归还了资金,选择获得项目方提供的 $500k 赏金
攻击分析
Ronin Bridge 除了在 2022 年因为私钥问题被黑客攻击过一次,在 2024 年也因为合约更新过程误操作问题被攻击了一次。
合约更新是为了将 V2 版本的跨链桥更新到 V4 版本的跨链桥,由于可升级合约的特性,在更新合约的时候需要调用提供的 `initialize` 函数来初始化新增的值,但是由于这次更新跨越了 V3 版本,在调用初始化函数的时候忘记调用了 V3 版本的初始化函数,导致本次攻击的发生。
更新前的合约初始化代码
contract MainchainGatewayV3 is
WithdrawalLimitation,
Initializable,
AccessControlEnumerable,
IMainchainGatewayV3,
HasContracts
{
...
/**
* @dev Initializes contract storage.
*/
function initialize(
address _roleSetter,
IWETH _wrappedToken,
uint256 _roninChainId,
uint256 _numerator,
uint256 _highTierVWNumerator,
uint256 _denominator,
// _addresses[0]: mainchainTokens
// _addresses[1]: roninTokens
// _addresses[2]: withdrawalUnlockers
address[][3] calldata _addresses,
// _thresholds[0]: highTierThreshold
// _thresholds[1]: lockedThreshold
// _thresholds[2]: unlockFeePercentages
// _thresholds[3]: dailyWithdrawalLimit
uint256[][4] calldata _thresholds,
Token.Standard[] calldata _standards
) external payable virtual initializer {
_setupRole(DEFAULT_ADMIN_ROLE, _roleSetter);
roninChainId = _roninChainId;
_setWrappedNativeTokenContract(_wrappedToken);
_updateDomainSeparator();
_setThreshold(_numerator, _denominator);
_setHighTierVoteWeightThreshold(_highTierVWNumerator, _denominator);
_verifyThresholds();
if (_addresses[0].length > 0) {
// Map mainchain tokens to ronin tokens
_mapTokens(_addresses[0], _addresses[1], _standards);
// Sets thresholds based on the mainchain tokens
_setHighTierThresholds(_addresses[0], _thresholds[0]);
_setLockedThresholds(_addresses[0], _thresholds[1]);
_setUnlockFeePercentages(_addresses[0], _thresholds[2]);
_setDailyWithdrawalLimits(_addresses[0], _thresholds[3]);
}
// Grant role for withdrawal unlocker
for (uint256 _i; _i < _addresses[2].length; ) {
_grantRole(WITHDRAWAL_UNLOCKER_ROLE, _addresses[2][_i]);
unchecked {
++_i;
}
}
}
function initializeV2(address bridgeManagerContract) external reinitializer(2){
_setContract(ContractType.BRIDGE_MANAGER, bridgeManagerContract);
}
/**
* @dev Receives ether without doing anything. Use this function to topup native token.
*/
function receiveEther() external payable {}
...
}
向右滑动查看详情
更新后的合约初始化代码
contract MainchainGatewayV3 is
WithdrawalLimitation,
Initializable,
AccessControlEnumerable,
ERC1155Holder,
IMainchainGatewayV3,
HasContracts,
IBridgeManagerCallback
{
...
/**
* @dev Initializes contract storage.
*/
function initialize(
address _roleSetter,
IWETH _wrappedToken,
uint256 _roninChainId,
uint256 _numerator,
uint256 _highTierVWNumerator,
uint256 _denominator,
// _addresses[0]: mainchainTokens
// _addresses[1]: roninTokens
// _addresses[2]: withdrawalUnlockers
address[][3] calldata _addresses,
// _thresholds[0]: highTierThreshold
// _thresholds[1]: lockedThreshold
// _thresholds[2]: unlockFeePercentages
// _thresholds[3]: dailyWithdrawalLimit
uint256[][4] calldata _thresholds,
TokenStandard[] calldata _standards
) external payable virtual initializer {
_setupRole(DEFAULT_ADMIN_ROLE, _roleSetter);
roninChainId = _roninChainId;
_setWrappedNativeTokenContract(_wrappedToken);
_updateDomainSeparator();
_setThreshold(_numerator, _denominator);
_setHighTierVoteWeightThreshold(_highTierVWNumerator, _denominator);
_verifyThresholds();
if (_addresses[0].length > 0) {
// Map mainchain tokens to ronin tokens
_mapTokens(_addresses[0], _addresses[1], _standards);
// Sets thresholds based on the mainchain tokens
_setHighTierThresholds(_addresses[0], _thresholds[0]);
_setLockedThresholds(_addresses[0], _thresholds[1]);
_setUnlockFeePercentages(_addresses[0], _thresholds[2]);
_setDailyWithdrawalLimits(_addresses[0], _thresholds[3]);
}
// Grant role for withdrawal unlocker
for (uint256 i; i < _addresses[2].length; i++) {
_grantRole(WITHDRAWAL_UNLOCKER_ROLE, _addresses[2][i]);
}
}
function initializeV2(address bridgeManagerContract) external reinitializer(2){
_setContract(ContractType.BRIDGE_MANAGER, bridgeManagerContract);
}
function initializeV3() external reinitializer(3){
IBridgeManager mainchainBridgeManager = IBridgeManager(getContract(ContractType.BRIDGE_MANAGER));
(, address[] memory operators, uint96[] memory weights) = mainchainBridgeManager.getFullBridgeOperatorInfos();
uint96 totalWeight;
for (uint i; i < operators.length; i++) {
_operatorWeight[operators[i]] = weights[i];
totalWeight += weights[i];
}
_totalOperatorWeight = totalWeight;
}
function initializeV4(address payable wethUnwrapper_) external reinitializer(4){
wethUnwrapper = WethUnwrapper(wethUnwrapper_);
}
/**
* @dev Receives ether without doing anything. Use this function to topup native token.
*/
function receiveEther() external payable { }
...
}
更新后的合约多了两个新的初始化函数initializeV3 和initializeV4
新增的初始化函数是为了初始化更新合约时添加的新变量初始化
但是在更新的过程中,操作者只调用了initializeV4 函数,漏掉了initializeV3
而initializeV3 函数是在初始化跨链桥操作者的权重,用于后续的去中心化治理,包括提款等行为的批准,都需要一定数量的操作者来签名验证,而这里没有初始化就会导致各个操作者的权重为 0,并且总权重为 0,而总权重为 0 会导致后续在处理需要一定数量操作者验证的操作时出现问题,拿本次攻击的核心函数submitWithdraw 举例
/**
* @inheritdoc IMainchainGatewayV3
*/
function submitWithdrawal(
Transfer.Receipt calldata _receipt,
Signature[] calldata _signatures
) external virtual whenNotPaused returns(bool _locked) {
return _submitWithdrawal(_receipt, _signatures);
}
/**
* @dev Submits withdrawal receipt.
*
* Requirements:
* - The receipt kind is withdrawal.
* - The receipt is to withdraw on this chain.
* - The receipt is not used to withdraw before.
* - The withdrawal is not reached the limit threshold.
* - The signer weight total is larger than or equal to the minimum threshold.
* - The signature signers are in order.
*
* Emits the `Withdrew` once the assets are released.
*
*/
function _submitWithdrawal(
Transfer.Receipt calldata _receipt,
Signature[] memory _signatures
) internalvirtualreturns(bool _locked){
uint256 _id = _receipt.id;
uint256 _quantity = _receipt.info.quantity;
address _tokenAddr = _receipt.mainchain.tokenAddr;
_receipt.info.validate();
if (_receipt.kind != Transfer.Kind.Withdrawal) revert ErrInvalidReceiptKind();
if (_receipt.mainchain.chainId != block.chainid) {
revert ErrInvalidChainId(msg.sig, _receipt.mainchain.chainId, block.chainid);
}
MappedToken memory _token = getRoninToken(_receipt.mainchain.tokenAddr);
if (!(_token.erc == _receipt.info.erc && _token.tokenAddr == _receipt.ronin.tokenAddr)) revert ErrInvalidReceipt();
if (withdrawalHash[_id] != 0) revert ErrQueryForProcessedWithdrawal();
if (!(_receipt.info.erc == Token.Standard.ERC721 || !_reachedWithdrawalLimit(_tokenAddr, _quantity))) {
revert ErrReachedDailyWithdrawalLimit();
}
bytes32 _receiptHash = _receipt.hash();
bytes32 _receiptDigest = Transfer.receiptDigest(_domainSeparator, _receiptHash);
uint256 _minimumVoteWeight;
(_minimumVoteWeight, _locked) = _computeMinVoteWeight(_receipt.info.erc, _tokenAddr, _quantity);
{
bool _passed;
address _signer;
address _lastSigner;
Signature memory _sig;
uint256 _weight;
for (uint256 _i; _i < _signatures.length; ) {
_sig = _signatures[_i];
_signer = ecrecover(_receiptDigest, _sig.v, _sig.r, _sig.s);
if (_lastSigner >= _signer) revert ErrInvalidOrder(msg.sig);
_lastSigner = _signer;
_weight += _getWeight(_signer);
if (_weight >= _minimumVoteWeight) {
_passed = true;
break;
}
unchecked {
++_i;
}
}
if (!_passed) revert ErrQueryForInsufficientVoteWeight();
withdrawalHash[_id] = _receiptHash;
}
if (_locked) {
withdrawalLocked[_id] = true;
emit WithdrawalLocked(_receiptHash, _receipt);
return _locked;
}
_recordWithdrawal(_tokenAddr, _quantity);
_receipt.info.handleAssetTransfer(payable(_receipt.mainchain.addr), _tokenAddr, wrappedNativeToken);
emit Withdrew(_receiptHash, _receipt);
}
向右滑动查看详情
在提款操作中,核心逻辑是先通过_computeMinVoteWeight 函数计算需要的最小权重,然后根据提交过来的验证者签名去计算权重是否满足需要的最小权重,如果满足,则进行提款操作的处理。
/**
* @dev Returns the minimum vote weight for the token.
*/
function _computeMinVoteWeight(
Token.Standard _erc,
address _token,
uint256 _quantity
) internal virtualreturns(uint256 _weight, bool _locked){
uint256 _totalWeight = _getTotalWeight();
_weight = _minimumVoteWeight(_totalWeight);
if (_erc == Token.Standard.ERC20) {
if (highTierThreshold[_token] <= _quantity) {
_weight = _highTierVoteWeight(_totalWeight);
}
_locked = _lockedWithdrawalRequest(_token, _quantity);
}
}
/**
* @inheritdoc GatewayV3
*/
function _getTotalWeight() internal view overridereturns(uint256){
return _totalOperatorWeight;
}
function _minimumVoteWeight(uint256 _totalWeight) internal view virtualreturns(uint256){
return (_num * _totalWeight + _denom - 1) / _denom;
}
向右滑动查看详情
可以看到先获取了_totalOperatorWeight
总权重,然后按照百分比去计算,但是这里总权重是 0,所以最后 _computeMinVoteWeight 函数返回的需要的最小权重即为 0,这就意味着不需要任何验证者进行签名,所以任何人都可以从跨链桥中进行提款。
结语
跨链桥是链接不同区块链的重要基础设施,其安全性会直接影响到跨链桥用户的资金安全,本文从三个方面对跨链桥的历史安全事件进行了分析,旨在提高跨链桥开发团队对安全性的把控,提高安全性。
原文始发于微信公众号(蚂蚁安全响应中心):天象Web3安全 | 跨链桥历史安全问题
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论