The Sandbox SAND 跨链桥攻击事件深度剖析:approveAndCall 委托劫持技术内幕

The Sandbox SAND 跨链桥攻击事件深度剖析:approveAndCall 委托劫持技术内幕

2026 年 8 月 21 日至 22 日,The Sandbox 的跨链桥遭遇了一次严重攻击,攻击者利用漏洞在 Base 和 BNB Smart Chain 上凭空铸造了数万亿枚 SAND 代币。虽然媒体头条疯狂渲染“490 亿美元”的巨额损失,但实际上攻击者真正从以太坊 escrow 合约中抽走的资金约为 67.5 万美元。名义上的天文数字与实际造成的经济损失之间存在着巨大鸿沟,这也使得该事件成为开发者和安全研究人员极具研究价值的经典案例。

本文将抛开耸人听闻的标题,从技术底层出发,逐步拆解漏洞的根源、攻击者的完整操作链路,并提供切实可行的代码修复方案。


一、架构解读:SAND 跨链桥本该如此工作

SAND 跨链桥基于 LayerZero 的 Omnichain Fungible Token(OFT) 标准构建,其正常运转逻辑如下:

  • SAND 被锁定在以太坊上的 OFT Adapter escrow 合约(地址:0xac531eb26ca1d21b85126de8fb87e80e09002dcf)中。
  • 当用户将 SAND 从以太坊跨链至 Base 时,以太坊的适配器锁定代币,Base 链上的 OFT 合约则负责铸造等额的 SAND。
  • 这里的 delegate(委托者) 是每条目标链上拥有管理权限的特殊地址,负责设定可信 peer、更新安全配置,以及调用代币合约中的特权函数。

核心假设在于:只有合法的 delegate 才能拥有铸币权限。而这次攻击,恰恰打破了这个假设。


二、漏洞根源:为什么 approveAndCall 成了致命弱点

什么是 approveAndCall?

approveAndCall 是 ERC-20 标准的一个扩展接口,本意是为了优化用户体验,允许用户在单笔交易中完成“授权”和“调用目标合约”两个动作。

// 存在漏洞的 approveAndCall 简化示意
function approveAndCall(address spender, uint256 amount, bytes calldata data) 
    external 
    returns (bool) 
{
    approve(spender, amount);
    (bool success, ) = spender.call(data);
    require(success, "Call failed");
    return true;
}
致命缺陷在哪里?

这次漏洞并非 LayerZero 协议本身的 Bug,而是 The Sandbox 在 应用层的配置失误。SAND OFT 合约中的 approveAndCall 函数存在三个严重的设计疏忽:

  1. 允许调用者指定任意的 spender 地址任意的 calldata
  2. 没有对谁可以调用该函数进行任何权限校验。
  3. 允许 spender 参数传入 LayerZero 的 endpoint 合约地址,并允许 calldata 调用 endpoint 上用于修改 delegate 权限的管理函数。

说白了,approveAndCall 变成了一扇没有门锁的后门,攻击者可以借此随意篡改 delegate 权限。


三、攻击全链路逐步拆解

第一阶段:Delegate 劫持(权限篡改)

攻击者使用了一个沉寂 313 天的休眠地址,向 Base 链上的 SAND OFT 合约提交了恶意交易。

  • 步骤 1:攻击者调用 SAND OFT 合约的 approveAndCall 函数,传入以下参数:
    • spender 设为 LayerZero 的 endpoint 地址。
    • data 是一段精心构造的 payload,用于指示 endpoint 将 delegate 权限授予攻击者的辅助合约。
  • 步骤 2approveAndCall 执行 approve(spender, amount),使 OFT 合约授权 endpoint 花费 SAND 代币。
  • 步骤 3:紧接着执行 spender.call(data),将恶意 payload 发送给 LayerZero endpoint。
  • 步骤 4:LayerZero endpoint 收到来自 OFT 合约的调用(对于 endpoint 来说,这是合法的内部调用),随即处理 payload,正式将 delegate 权限移交给了攻击者的辅助合约

为什么能成功? LayerZero endpoint 无条件信任来自 OFT 合约的调用。它并不知道 OFT 合约本身是在被 approveAndCall 这个“傀儡”操纵的情况下发起调用的。

第二阶段:无限铸币(虚假天量供应)

一旦攻击者掌握了 delegate 权限,OFT 合约就不再需要在以太坊上有实际销毁或锁定记录,就能在目标链上随意铸币。

// delegate 现在可以调用原本仅允许合法 delegate 调用的铸币函数
function mint(address to, uint256 amount) external onlyDelegate {
    _mint(to, amount);
}

攻击者随后在 Base 链上进行了疯狂的虚假铸币操作:

  • 总计铸造了 329.24 万亿枚 SAND(PeckShield 统计为约 149 亿枚,数据口径差异源于统计范围,但实际数量级远超总供应量)。
  • 分散发送至 173 个不同地址
  • 按当时市价估算名义价值高达约 490 亿美元
第三阶段:真实获利套现(抽干 Escrow)

虽然绝大多数虚假代币被隔离在了 Base 链上,但攻击者并未止步。他们利用被篡改的 delegate 权限,向以太坊主网提交了跨链赎回消息

  • 攻击者抽空了以太坊 OFT Adapter escrow 合约中锁定的真实 SAND。
  • 成功提取 14,753,431 枚 SAND
  • 整个抽离过程发生在 UTC 时间 00:32 至 01:22 之间,实际执行仅用了不到 60 秒
  • 提取到的真实 SAND 被迅速卖出,获利约 80 枚 ETH(约 67.5 万美元)

为何获利如此有限? 因为以太坊 escrow 合约中锁定的真实 SAND 只有约 1475 万枚。即便攻击者在 Base 上铸造了天文数字的假币,也无法兑换出超过 escrow 实际存款的金额。正是这种“底层资产背书”的机制,限制了攻击者的最终实际获利。


四、“490 亿美元”只是数字游戏

所谓的 490 亿美元,只是用铸造出来的虚假代币数量乘以市场价格得出的理论值。这个数字极具误导性:

  • 这是账面价值,而非实际价值:根本没有 490 亿美元的真实资产可供提取。
  • 流动性根本不足以支撑:在市场上抛售万亿级别的代币,价格会瞬间归零。
  • 代币无真实背书:这些铸造出来的代币在以太坊上没有对应的锁仓 SAND。

要知道,SAND 当时的总市值仅约 1.4 亿美元,最大总供应量仅 30 亿枚。攻击者铸造的数量远超总供应量数倍。最终的实际财务影响仅限于从以太坊 escrow 中抽走的约 1475 万枚 SAND,Sandbox 官方估计损失不足 SAND 总供应量的 0.01%


五、根因总结(要点形式)

不要再用表格了,我们直接用要点罗列关键信息:

  • 漏洞类型:访问控制失效 / 权限提升
  • 攻击向量approveAndCall 函数滥用
  • 被攻破的核心组件:LayerZero 的 Delegate(委托者)权限
  • 根本原因approveAndCall 缺少调用者校验,导致外部攻击者可借助 OFT 合约的身份,间接篡改 LayerZero Endpoint 上的 Delegate 配置
  • 协议层面:属于应用层配置缺陷,而非 LayerZero 底层协议漏洞

六、硬核修复方案:代码层面的对症下药

修复方案一:为 approveAndCall 增加白名单校验
// 修复前(存在漏洞)
function approveAndCall(address spender, uint256 amount, bytes calldata data) 
    external 
    returns (bool) 
{
    approve(spender, amount);
    (bool success, ) = spender.call(data);
    require(success, "Call failed");
    return true;
}

// 修复后(增加白名单限制)
mapping(address => bool) public whitelistedSpenders;
mapping(address => bool) public whitelistedTargets;

function approveAndCall(address spender, uint256 amount, bytes calldata data) 
    external 
    returns (bool) 
{
    // 限制只能授权给预先批准的白名单地址
    require(whitelistedSpenders[spender], "Spender not whitelisted");
    
    // 限制目标合约地址必须在白名单内
    address target = abi.decode(data[0:20], (address));
    require(whitelistedTargets[target], "Target not whitelisted");
    
    approve(spender, amount);
    (bool success, ) = spender.call(data);
    require(success, "Call failed");
    return true;
}

效果:从根本上杜绝了向任意地址授权和调用任意合约的可能性。

修复方案二:直接弃用 approveAndCall(彻底移除)
// 直接删除 approveAndCall 函数
// 用户必须手动分步执行 approve() 和后续的目标调用

效果:最干净利落的做法,彻底移除攻击面。对于用户体验的影响微乎其微,用户完全可以通过两笔交易完成同样的操作。

修复方案三:对 Delegate 变更实施多签控制
// 修复前(单点风险)
function setDelegate(address _delegate) external onlyDelegate {
    delegate = _delegate;
}

// 修复后(多签流程)
address[] public owners;
mapping(address => bool) public isOwner;
uint256 public requiredSignatures;

struct DelegateProposal {
    address proposedDelegate;
    uint256 approvals;
    mapping(address => bool) hasApproved;
    bool executed;
}

mapping(uint256 => DelegateProposal) public proposals;
uint256 public proposalCount;

function proposeDelegateChange(address _newDelegate) external {
    require(isOwner[msg.sender], "Not an owner");
    proposalCount++;
    proposals[proposalCount].proposedDelegate = _newDelegate;
    proposals[proposalCount].approvals = 1;
    proposals[proposalCount].hasApproved[msg.sender] = true;
}

function approveDelegateChange(uint256 _proposalId) external {
    require(isOwner[msg.sender], "Not an owner");
    require(!proposals[_proposalId].hasApproved[msg.sender], "Already approved");
    
    proposals[_proposalId].approvals++;
    proposals[_proposalId].hasApproved[msg.sender] = true;
    
    if (proposals[_proposalId].approvals >= requiredSignatures) {
        delegate = proposals[_proposalId].proposedDelegate;
        proposals[_proposalId].executed = true;
    }
}

效果:任何 delegate 的变更都必须经过多个私钥持有者批准,单一私钥泄露无法导致权限被恶意篡改。The Sandbox 在事后处置中也是通过多签移除了 LayerZero peer 设置,从而遏制了事态恶化。

修复方案四:引入铸币速率限制与实时监控
uint256 public mintRateLimit = 1_000_000 * 10**18; // 每区块限制铸造 100 万枚
uint256 public lastMintBlock;
uint256 public mintedThisBlock;

function mint(address to, uint256 amount) external onlyDelegate {
    if (block.number != lastMintBlock) {
        lastMintBlock = block.number;
        mintedThisBlock = 0;
    }
    require(mintedThisBlock + amount <= mintRateLimit, "Mint rate limit exceeded");
    mintedThisBlock += amount;
    _mint(to, amount);
}

效果:即使 delegate 权限被恶意控制,攻击者也无法在短时间内批量铸造天量代币,为防守方争取到了宝贵的检测和应急响应时间。

修复方案五:部署全局紧急暂停开关
bool public paused;

modifier whenNotPaused() {
    require(!paused, "Contract paused");
    _;
}

function pause() external onlyMultiSig {
    paused = true;
}

function unpause() external onlyMultiSig {
    paused = false;
}

// 在所有敏感函数上应用该修饰器
function mint(address to, uint256 amount) external onlyDelegate whenNotPaused {
    _mint(to, amount);
}

效果:赋予项目方在检测到异常活动时一键暂停合约的能力。The Sandbox 在这次事件中就成功启用了暂停功能,及时切断了 Base 和 BNB Chain 的跨链通道。


七、跨链安全启示录

回顾这次事件,我们能从中提炼出几点极具价值的经验教训:

Delegate 权限是整个 OFT 部署的命门。对它的保护等级,必须等同于私钥或多签钱包的管理。任何能够修改 delegate 配置的函数,都必须叠加多层访问控制,绝不能偷懒。

像 approveAndCall 这类 ERC-20 扩展函数是把双刃剑。它们为了前端便利而生,但在协议交互场景下往往会引入不可预知的副作用。项目方在集成这类函数时,必须严格审计其与外部协议(尤其是跨链 Endpoint)的交互边界。

务必区分“账面损失”和“实际损失”。媒体为了流量炮制的“490 亿”标题很容易引发不必要的市场恐慌。安全人员在对外披露时,应当清晰地告知社区真正的实际影响范围。

响应速度决定了事故的最终量级。这次攻击中,Sandbox 团队在监测到异常后迅速响应,在攻击者抽走更多资金之前成功封锁了桥梁。这提醒我们,链上监控系统和紧急响应预案绝对不是可有可无的摆设。

最后,再次强调:这次事故不是 LayerZero 协议的锅,而是项目方应用层配置不当导致的。任何使用 OFT 标准的项目方,都应当立即对照上述修复方案进行自查,并定期开展针对 delegate 管理流程的安全审计。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

MartinYeung5

感謝你的支持與肯定

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值