通过链上真实黑客攻击分析一些桥相关协议或者涉及到桥实现漏洞被黑的。
最后也找了两个类似的漏洞报告中出现的。

我可以想到的最近的有:port3 GyroStable CrossCurve Spoke
其中GriffinAI,Seedify SFUND Bridge,Yala,Shibarium Bridge设计到私钥/内部权限等问题就不说了。
0xswapnet ApertureFinance关于任意调用导致transferFrom()这类就不说了,因为并没有涉及到桥协议。但是后续在看审计报告也发现了类似的。

后面有可以在补充。

跨链基本了解

跨链基本原理其实就是“锁定 + 铸造” / “烧毁 + 解锁”。
其实只知道参数就可以了。参数名称和实现都大差不差。
注意验证方式也都不太一样,但是也就那几种:多节点 MPC,多签,去中心化预言机+DVN,多签 + 外部验证者….

一些常见参数:

参数名称(常见叫法) 类型 含义 / 例子(BSC→ETH) 是否必填 备注
token address BSC上的代币地址 必填
amount uint256 数量 必填
toChainId uint16/uint256 目标链ID 必填 每个桥的链ID定义可能略不同
toAddress/recipient bytes32/address 收币地址 必填 有时要转成 bytes32
fromChainId /sourceChain uint16/uint256 来源链ID 验证用
nonce / sequence uint64/uint256 防重放序号 防止同笔消息重复执行
payload / vaa / message bytes 编码后的全部跨链信息(上面字段打包) Wormhole叫VAA,LayerZero叫payload
signatures bytes[] 验证者签名数组 多签桥要收集足够数量签名
transferFee / protocolFee uint256 手续费

真实世界:

port3

黑客地址:0xb13a503da5f368e48577c87b5d5aec73d08f812e
漏洞合约: https://bscscan.com/address/0xb4357054c3da8d46ed642383f03139ac7f090343#code
直接看最黑客最开始的操作。
https://app.blocksec.com/phalcon/explorer/tx/bsc/0xfaf450571541b95f924024ac3febd5cf6c16695ce787217ca8870350309051c1?debugLine=0&line=0&showSStore=true&showStaticCall=true
首先进入了registerChain(哪些链上的哪个代币合约地址发来的跨链消息是可信的,可以让我 mint 出代币。)

但是这里有个问题明明有修饰符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/// @dev verify owner is caller or the caller has valid owner signature
modifier onlyOwnerOrOwnerSignature(
CATERC20Structs.SignatureVerification memory signatureArguments
) {
if (_msgSender() == owner()) {
_;
} else {
bytes32 encodedHashData = prefixed(
keccak256(
abi.encodePacked(signatureArguments.custodian, signatureArguments.validTill)
)
);
require(signatureArguments.custodian == _msgSender(), "custodian can call only");
require(signatureArguments.validTill > block.timestamp, "signed transaction expired");
require(
isSignatureUsed(signatureArguments.signature) == false,
"cannot re-use signatures"
);
setSignatureUsed(signatureArguments.signature);
require(
verifySignature(encodedHashData, signatureArguments.signature, owner()),
"unauthorized signature"
);
_;
}

读了一下_OWNER是0地址,0x0000000000000000000000000000000000000000。
所以owner == address(0)。
verifySignature也可以看到authority是0地址(OWNER)【没有做检查】
最终来到

1
2
3
4
5
6
7
8
9
10
11
12
13
function verifySignature(
bytes32 message,
bytes memory signature,
address authority
) internal pure returns (bool) {
(uint8 v, bytes32 r, bytes32 s) = splitSignature(signature);
address recovered = ecrecover(message, v, r, s);
if (recovered == authority) {
return true;
} else {
return false;
}
}

现在任何签名让 ecrecover 恢复出 address(0),就会被认为是“owner 签名
所以现在黑客地址变成了合法的远程代币合约地址”

现在就可以开始mint了。发出 Wormhole 消息伪造 VM(因为 emitterAddress 被注册了,所以合约认为“合法”)
https://app.blocksec.com/phalcon/explorer/tx/bsc/0x34c17a91b2f2ccd5973ecd49c20cc3c0939c5d8eaeeb740e9dec97fb1345e1da

1
2
3
4
5
6
7
8
9
10
11
12
13
function bridgeIn(bytes memory encodedVm) external returns (bytes memory) {
require(isInitialized() == true, "Not Initialized");
require(evmChainId() == block.chainid, "unsupported fork");

(IWormhole.VM memory vm, bool valid, string memory reason) = wormhole().parseAndVerifyVM(
encodedVm
);
require(valid, reason);
require( //利用漏洞注册成了合法的 emitterAddress
bytesToAddress(vm.emitterAddress) == address(this) ||
tokenContracts(vm.emitterChainId) == vm.emitterAddress,
"Invalid Emitter"
);

执行 bridgeIn → mint 了 10 亿枚 PORT3

根本原因:

上述讲过了,”在verifySignature也可以看到authority是0地址(OWNER)【没有做检查】”

GyroStable

黑客地址: 0x7DD4075A6eAe9f18309F112364f0394C2DfA8102
漏洞合约:
arb:0xCA5d8F8a8d49439357d3CF46Ca2e720702F132b8(漏洞利用时候的impl: 0xE8ab4550dFa163753023Da3154234a525C8eF863)
eth:0xa1886c8d748deb3774225593a70c79454b1da8a6(impl:0xe07f9d810a48ab5c3c914ba3ca53af14e4491e8a)

我认为逻辑在arb侧的L2Gyd.bridgeToken()的和eth侧的GydL1CCIPEscrow.ccipReceive() 都有问题。但是eth侧的GydL1CCIPEscrow.ccipReceive()问题最大,一般接收消息更应该做好处理。

3次tx完成整个攻击。

TX1:
https://app.blocksec.com/phalcon/explorer/tx/eth/0x45739a92c2d99f172a74d8028736a2fd1b507ac6fc134680cd1dccd3c572c600
第一个Tx是利用漏洞的,当时第一眼就看到了0.000000000000000001GYD。
(在绝大多数协议中,除了价格问题,发送1WEI或者极小值那就是为了发送其他data来使用的。这里因为没有涉及到其他的借款借贷等操作,所以是为了实现一些data)。
https://calldata.swiss-knife.xyz/decoder?tx=0x51c22898a9b9f519a10b0a0be89b9d51c0248adb80cc0f89e57437e15e6c60c7&chainId=42161
可以看到调用bridgeToken()方法传入了approve给spender地址超大额的授权。

这会发送CCIP消息到L1 escrow(https://etherscan.io/address/0x79ec4a4440878b25b0cbd902fb3414015c28bbc5)。

TX2:
https://app.blocksec.com/phalcon/explorer/tx/eth/0x45739a92c2d99f172a74d8028736a2fd1b507ac6fc134680cd1dccd3c572c600?line=36&showStaticCall=true
ETH侧的GydL1CCIPEscrow.ccipReceive()验证发送者(Arbitrum的L2Gyd),解码消息,转移1 wei到GYD(无影响,只是满足参数),然后执行GYD.functionCall(data) → GYD.approve(attacker, max),以escrow为msg.sender。

攻击完成闭环。

TX3:
https://app.blocksec.com/phalcon/explorer/tx/eth/0xe03ac744df1910a71fedab58bc6a32ab5afe1cb4fcad94a0e5c8d7edf0d7405c?showStaticCall=true
最后
攻击者直接调用GYD的transferFrom(escrow地址, attacker地址, escrow的GYD余额),抽走全部资金。

根本原因:

在ARB侧L2Gyd.bridgeToken()函数中。
这里允许用户任意指定 recipient 和 data,没有任何限制。

这里的ccipReceive()也有问题,不说了。和ETH侧一样。

ETH侧的GydL1CCIPEscrow.ccipReceive()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
 /// @dev handle a received message
/// the authentification is done in the parent contract
function _ccipReceive(Client.Any2EVMMessage memory any2EvmMessage)
internal
override
{
address expectedSender =
chainsMetadata[any2EvmMessage.sourceChainSelector].targetAddress;
if (expectedSender == address(0)) {
revert ChainNotSupported(any2EvmMessage.sourceChainSelector);
}
address actualSender = abi.decode(any2EvmMessage.sender, (address));
if (expectedSender != actualSender) {
revert MessageInvalid();
}
//没验证
(address recipient, uint256 amount, bytes memory data) =
abi.decode(any2EvmMessage.data, (address, uint256, bytes));
uint256 bridged = totalBridgedGYD;
bridged -= amount;
totalBridgedGYD = bridged;

gyd.safeTransfer(recipient, amount);
if (data.length > 0) {
recipient.functionCall(data);
}

emit GYDClaimed(
any2EvmMessage.sourceChainSelector, recipient, amount, bridged
);
}

这里允许任意 recipient + 任意 data → recipient.functionCall(data)

CrossCurve

这起攻击也有其他机器人参与,这里只举例一个
黑客地址: 0x632400f42e96a5deb547a179ca46b02c22cd25cd
漏洞合约:0xb2185950f5a0a46687ac331916508aada202e063
攻击TX: https://app.blocksec.com/phalcon/explorer/tx/eth/0x37d9b911ef710be851a2e08e1cfc61c2544db0f208faeade29ee98cc7506ccc2

其实刚开始看的时候我还不知道expressExecute和Execute有什么区别,后面才知道这个是漏洞利用的关键。所以看了攻击tx发现有点离谱(攻击者直接就),进入后几个验证就到了PortalV2.unlock解锁和释放资产了。

首先看传输路由:交易先进入expressExecute。

随后经过一个简单的验证
if (gateway.isCommandExecuted(commandId)) revert AlreadyExecuted();
来判断commandId是不是之前有过的。

然后继续往下走。
进入了AxelarExpressExecutable.setExpressExecutor
setExpressExecutor(记录调用者为 expressExecutor),并调用 execute(sourceChain, sourceAddress, payload) 来执行 payload。
随后进入了AxelarExpressExecutable.execute
看execute的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function execute(
bytes32 commandId,
string calldata sourceChain,
string calldata sourceAddress,
bytes calldata payload
) external {
bytes32 payloadHash = keccak256(payload);

if (!gateway.validateContractCall(commandId, sourceChain, sourceAddress, payloadHash))
revert NotApprovedByGateway();

address expressExecutor = _popExpressExecutor(commandId, sourceChain, sourceAddress, payloadHash);

if (expressExecutor != address(0)) {
// slither-disable-next-line reentrancy-events
emit ExpressExecutionFulfilled(commandId, sourceChain, sourceAddress, payloadHash, expressExecutor);
} else {
_execute(sourceChain, sourceAddress, payload); //随后进入
}
}

如果直接向execute传参,那么会进入
gateway.validateContractCall(commandId, sourceChain, sourceAddress, payloadHash) 。
会验证消息的来源(sourceChain、sourceAddress)和 payload 的哈希是否由 Axelar 网关认证。

但是向expressExecute传参会进入_execute(sourceChain, sourceAddress, payload);
由于是ReceiverAxelar,所以直接绕过了检查。
然后进入了Receiver.receiveData传入黑客构造的恶意参数。

然后就是CoreFacet.resume 然后到 PortalV2.unlock。这里就不详细说了,漏洞不在这里。

综上:expressExecute 本意是 Axelar 的“快速执行”模式,允许用户预支付 gas 来加速跨链调用(后续由真实消息退款)。但在 CrossCurve 的实现中,它允许攻击者注入任意 sourceChain、sourceAddress 和 payload,直接触发内部逻辑,而不等待真实跨链消息的到来和验证。这相当于打开了一个“后门”,让攻击者伪造跨链指令。

根本原因:

expressExecute 是函数完全公开、无权限控制、无来源验证的入口
该函数允许任何人用任意参数直接调用 _execute,而没有任何跨链消息真实性校验。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  function expressExecute(
bytes32 commandId,
string calldata sourceChain,
string calldata sourceAddress,
bytes calldata payload
) external payable virtual {
if (gateway.isCommandExecuted(commandId)) revert AlreadyExecuted();
// 下面没有任何验证就直接信任了调用者提供的所有参数
address expressExecutor = msg.sender;
bytes32 payloadHash = keccak256(payload);

emit ExpressExecuted(commandId, sourceChain, sourceAddress, payloadHash, expressExecutor);

_setExpressExecutor(commandId, sourceChain, sourceAddress, payloadHash, expressExecutor);

_execute(sourceChain, sourceAddress, payload);
}

Spoke

这次攻击其实不涉及到跨桥协议。
之前分析过,直接贴过来了。

黑客地址:
https://bscscan.com/address/0xc6e8210e47602860c97edc0bd7556641f048868e
漏洞合约:
https://bscscan.com/address/0xdf4fFDa22270c12d0b5b3788F1669D709476111E
攻击合约:
https://bscscan.com/address/0xe4b8d09c12d15f2934f21ca8eaa5dcb1464f9ed1
攻击TX: https://app.blocksec.com/phalcon/explorer/tx/bsc/0xcd345310e491195f0500d45d6987eaef342bae24390f4da4f7e6749b8105b4c3

这个漏洞调用栈比较少, 跟着调用流程Debug:

可以看到先进行sponsorOrderUsingPermit2()函数操作。

sponsorOrderUsingPermit2()接收3个参数()signature不说了。
看传参:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
order:[
{
fromAddress:"0xe4b8d09c12d15f2934f21ca8eaa5dcb1464f9ed1"
toAddress:"0xe4b8d09c12d15f2934f21ca8eaa5dcb1464f9ed1"
filler:"0xe4b8d09c12d15f2934f21ca8eaa5dcb1464f9ed1"
fromToken:"0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d" //USDC
toToken:"0x0000000000000000000000000000000000000000"
expiry:"1,759,825,777"
fromAmount:"15,121,014,087,537,935,893,820"
fillAmount:"0"
feeRate:"0"
fromChain:"56"
toChain:"56"
postHookHash:"0x0000000000000000000000000000000000000000000000000000000000000000"
}
1
2
3
4
5
6
permit:[
{permitted:[
{token:"0xe4b8d09c12d15f2934f21ca8eaa5dcb1464f9ed1"
amount:"15,121,014,087,537,935,893,820"}]
nonce:"59,946,715,367,315,085,452,573,067,176,193,223,667,943,792,355,376,429,741,185,393,233,268,031,513,012"
deadline:"1,759,825,777"}

根据参数可以拿出来主要信息:
order.fromToken:”0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d” //USDC
permit.permitted.token:”0xe4b8d09c12d15f2934f21ca8eaa5dcb1464f9ed1” //攻击者合约,里面有erc20实现,所以被认为token。

看一下sponsorOrderUsingPermit2方法的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/// @inheritdoc ISpoke
function sponsorOrderUsingPermit2(
Order calldata order,
IPermit2.PermitTransferFrom calldata permit,
bytes calldata signature
) external nonReentrant {
bytes32 orderHash = keccak256(abi.encode(order));

if (orderHashToStatus[orderHash] != OrderStatus.EMPTY) revert OrderAlreadyExists();
if (block.timestamp > order.expiry) revert OrderExpired();
if (order.fromChain != _getChainId()) revert InvalidSourceChain();
if (order.fromToken == Utils.NATIVE_TOKEN) revert NativeTokensNotAllowed();

orderHashToStatus[orderHash] = OrderStatus.CREATED;

IPermit2.SignatureTransferDetails memory transferDetails;
transferDetails.to = address(this);
transferDetails.requestedAmount = order.fromAmount;

bytes32 witness = _hashOrderTyped(order);
permit2.permitWitnessTransferFrom(
permit,
transferDetails,
order.fromAddress,
witness,
Utils.ORDER_WITNESS_TYPE_STRING,
signature
);

emit OrderCreated(orderHash, order);
}

可以看出来该函数是一个用于创建订单的接口。
通过 Permit2(这里不详细解释了) 机制,从 order.fromAddress 转移 permit.permitted.token 的 order.fromAmount 到合约,创建订单并标记状态为 CREATED。

还可以跨链或同链代币交易场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
struct Order {
// Address that will supply the fromAmount of fromToken on the fromChain.
address fromAddress;
// Address to receive the fillAmount of toToken on the toChain.
address toAddress;
// Address that will fill the Order on the toChain.
address filler;
// Address of the ERC20 token being supplied on the fromChain.
// 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE in case of native token.
address fromToken;
// Address of the ERC20 token being supplied on the toChain.
// 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE in case of native token.
address toToken;
// Expiration in UNIX for the Order to be created on the fromChain.
uint256 expiry;
// Amount of fromToken to be provided by the fromAddress.
uint256 fromAmount;
// Amount of toToken to be provided by the filler.
uint256 fillAmount;
// Protocol fees are taken out of the fromAmount and are calculated within the Spoke.sol
// contract for single chain orders or on the Hub for cross chain orders.
// The following formula determines the amount of fromToken reserved as fees:
// fee = (fromAmount * feeRate) / 1000000
uint256 feeRate;
// Chain ID of the chain the Order will be created on.
uint256 fromChain;
// Chain ID of the chain the Order will be filled on.
uint256 toChain;
// Keccak256 hash of the abi.encoded ISquidMulticall.Call[] calldata calls that should be provided
// at the time of filling the order.
bytes32 postHookHash;
}

根据攻击者输入结合合约代码可以看出:合约未验证 permit.permitted.token == order.fromToken
那么攻击流程就很明了了:
攻击者存入假代币
记录USDC
后续可以通过 refundOrder() 提取USDC

现在类比下整个流程:

  • 你告诉银行(合约)要存 1000 USDC(order.fromToken)
  • 但你通过签名(permit)实际给了 1000 张废纸(假代币)
  • 银行记录你存了 1000 USDC(orderHashToStatus = CREATED)
  • 后来你说“退款refundOrder ”,银行按记录退给你 1000 USDC,而没检查你实际存了什么钱

根本原因

合约中sponsorOrderUsingPermit2函数中没有正确验证 permit.permitted.tokenorder.fromToken 一致,攻击者可以伪造一个签名,
把假代币地址传进去,导致“用假币换真币”

一些漏洞报告里面的

LI.FI:Too generic calls in GenericBridgeFacet allow stealing of tokens

漏洞报告: https://solodit.cyfrin.io/issues/too-generic-calls-in-genericbridgefacet-allow-stealing-of-tokens-spearbit-lifi-pdf

这个漏洞是22年发现的,会发现最近的几起攻击,比如0xswapnet ApertureFinance有点类似。(只是攻击路径有点不同,但是原理差不多。因为没分析0xswapnet ApertureFinance,所以这里来说一下)

相关漏洞代码:GenericBridgeFacet: https://github.com/lifinance/contracts/blob/f024ee5d64a24882010642bf81d87529712edc7c/src/Facets/GenericBridgeFacet.sol#L69-L120
LibSwap: https://github.com/lifinance/contracts/blob/f024ee5d64a24882010642bf81d87529712edc7c/src/Libraries/LibSwap.sol#L30-L68

在LibSwap.swap()中

1
2
// solhint-disable-next-line avoid-low-level-calls
(bool success, bytes memory res) = _swapData.callTo.call{ value: nativeValue }(_swapData.callData);

这里,_swapData.callTo_swapData.callData 完全由用户输入控制。
没有检查callTo是否是合法的DEX地址,也没有验证callData是否是安全的函数签名

在GenericBridgeFacet.startBridge()中

1
(bool success, bytes memory res) = _bridgeData.callTo.call{ value: value }(_bridgeData.callData);

同样,_bridgeData.callTo_bridgeData.callData 来自用户输入。
这种设计是为了支持“任意桥接”,但它本质上是一个“任意代码执行”入口点。Solidity的低级调用允许执行任何合约的任何函数,只要有足够的权限。

在LibSwap.swap()中,在调用前执行:

1
2
3
4
5
6
7
8
if (!LibAsset.isNativeAsset(fromAssetId)) {
LibAsset.maxApproveERC20(IERC20(fromAssetId), _swapData.approveTo, fromAmount); //调用这个
if (toDeposit != 0) {
LibAsset.transferFromERC20(fromAssetId, msg.sender, address(this), toDeposit);
}
} else {
nativeValue = fromAmount;
}

这会批准approveTo(用户指定)从合约中转移fromAmount数量的代币。

在_startBridge()中

1
LibAsset.maxApproveERC20(IERC20(_bridgeData.assetId), _bridgeData.callTo, _bridgeData.amount);

批准callTo从合约转移代币。

问题在于:如果攻击者将callTo设置为受害者代币的合约地址,并将callData编码为transferFrom(victim, attacker, amount),那么合约就会代表自己(LiFi合约)调用transferFrom()。如果受害者之前批准了LiFi合约转移其代币,攻击者就能窃取这些代币。

在swapAndStartBridgeTokensGeneric()中,不直接从msg.sender转移;它依赖交换逻辑。
但在LibSwap.swap()中,如果需要,它会从msg.sender转移额外代币到合约:

1
2
3
if (toDeposit != 0) {
LibAsset.transferFromERC20(fromAssetId, msg.sender, address(this), toDeposit);
}

这确保合约有足够的余额进行交换,但结合任意调用,攻击者可以先转移自己的小额代币,然后用任意调用窃取他人代币。

综上,攻击就很清楚了:

攻击者调用swapAndStartBridgeTokensGeneric():
构造一个LibSwap.SwapData 或 BridgeData:

1
2
3
4
5
6
{
callTo:设置为受害者代币的地址
approveTo:设置为USDC合约地址(或攻击者控制的地址)。
callData:编码为USDC的transferFrom(victim, attacker, amount) 函数调用:transferFrom(address,address,uint256)", victim, attacker, amount
sendingAssetId 和 receivingAssetId 可以设置为任意,只要匹配代币就可以。
}

根本原因

合约允许用户完全控制低级调用的目标地址(callTo)和调用数据(callData),却没有对调用目标和函数签名进行任何有效限制或白名单校验,导致攻击者可以直接让合约调用任意 ERC20 的 transferFrom(),窃取已授权给 LiFi 合约的大额代币。

LI.FI: Bridge with Axelar can be stolen with malicious external call

这个也不说了,有点像CrossCurve的。
但是有一些区别:
这个漏洞的Receiver 在”已验证的跨链消息”上执行了危险的任意逻辑.
相当于合法 Axelar 消息 + 过度信任 payload

这个就不分析了:

报告链接: https://solodit.cyfrin.io/issues/bridge-with-axelar-can-be-stolen-with-malicious-external-call-spearbit-lifi-pdf

涉及到的代码:
https://github.com/lifinance/contracts/blob/f024ee5d64a24882010642bf81d87529712edc7c/src/Periphery/Executor.sol#L272-L288
https://github.com/lifinance/contracts/blob/f024ee5d64a24882010642bf81d87529712edc7c/src/Periphery/Executor.sol#L323-L333
https://github.com/lifinance/contracts/blob/f024ee5d64a24882010642bf81d87529712edc7c/src/Periphery/Executor.sol#L269-L288