资讯动态

MakerDaoBot 被掏走 200 ETH:一个漏写的权限检查,代价 54 万美元

发布时间:2026/10/15 1:22:50 来源:尧图企业网站定制
MakerDaoBot 被掏走 200 ETH一个漏写的权限检查代价 54 万美元2026 年 10 月 6 日一笔以太坊交易从休眠的 MakerDAO 清算 keeper 里拿走了 200 ETH约 54.2 万美元。攻击者没有私钥没有内部权限也没有 MakerDAO 核心合约的 0day。问题出在一个函数忘了检查msg.sender。这个漏洞不新鲜甚至可以说很老套。但它还是出现在生产环境里而且一放就是六年半。下面我把整个过程拆开看看代码到底哪里错了以及正确的写法应该是什么样。一、背景Black Thursday 之后沉睡的 200 ETH故事要从 2020 年 3 月的 Black Thursday 说起。当时有一个第三方清算 keeper通过可升级代理合约0x9c05...8273参与 MakerDAO 的 ETH-A 抵押品拍卖。它赢了四场拍卖编号从 #1457 到 #1460每场包含 50 ETH 的抵押品报价都是零。四场加起来正好 200 ETH。但赢下 MakerDAO Flipper 拍卖不等于抵押品会自动到账。赢家还需要在拍卖结束后调用deal()完成结算才能拿到 ETH。这个 keeper 的所有者没有调用。2020 年 3 月之后所有者就不再和它交互了。于是这 200 ETH 一直留在 MakerDAO 的 Vat 账本里记在 keeper 地址名下等了六年半等到了攻击者。2026 年 10 月攻击者从一个地址通过 Tornado Cash 拿到 0.1 ETH部署了攻击合约然后执行了抽干操作。 stolen funds 在 16 分钟内被拆成 20 笔 10 ETH重新存回 Tornado Cash。二、漏洞函数0x8804d1de到底做了什么攻击者利用的是 keeper 实现合约0x6839...5546里的一个函数函数选择器是0x8804d1de。它的逻辑大致是这样的接收一个地址参数varg0把它当成 adapter 合约。调用adapter.vat()拿到 Vat 地址确认这个 adapter 能和 Vat 交互。检查 adapter 是否已经有权限操作 keeper 在 Vat 里的余额。如果没有就调用Vat.hope(varg0)给这个 adapter 授权。然后调用adapter.join()把执行控制权交给 adapter 的代码。问题就在最后两步。这个函数先授权然后立刻把控制权交给刚被授权的合约而且整个过程没有任何msg.sender检查也没有ds-auth修饰器。反编译后的逻辑大概是这样function 0x8804d1de(uint256 arg0, uint256 arg1) public { require(msg.data.length - 4 64); // 校验 adapter 地址 require(address(arg0).code.size 0); // 调用 adapter.drip() 更新利率 (bool success1,) address(arg0).call(abi.encodeWithSelector(0x...)); require(success1); // 从 adapter 获取 Vat 地址 (bool success2, bytes memory vatData) address(arg0).call( abi.encodeWithSelector(0x...) ); require(success2); address vat abi.decode(vatData, (address)); // 检查 adapter 是否能操作 keeper 的 Vat 余额 (bool success3, bytes memory canData) vat.call( abi.encodeWithSelector(VatLike.can.selector, address(this), address(arg0)) ); require(success3); bool alreadyAuthorized abi.decode(canData, (bool)); if (!alreadyAuthorized) { // 授予权限但没有检查是谁调用了这个函数 vat.call(abi.encodeWithSelector(VatLike.hope.selector, address(arg0))); } // 把控制权交给 adapter.join()此时权限已经生效 address(arg0).call(abi.encodeWithSelector(AdapterLike.join.selector, arg1)); }要理解为什么这很致命得先知道Vat.hope()到底干了什么。三、MakerDAO Vat 的权限模型MakerDAO 的 Vat 是内部账本记录每个地址的抵押品gem[ilk][address]和 DAI 余额dai[address]。它没有单独的钱包合约。你的 Vat 余额就是记录在你地址下的一条账目。hope()不会移动任何余额。它只是设置can[msg.sender][usr] 1允许usr在操作msg.sender名下余额时通过wish()权限检查。相关代码大致如下mapping(address mapping(address uint)) public can; function hope(address usr) external note { can[msg.sender][usr] 1; } function nope(address usr) external note { can[msg.sender][usr] 0; } function wish(address bit, address usr) internal view returns (bool) { return either(bit usr, can[bit][usr] 1); } function flux(bytes32 ilk, address src, address dst, uint256 wad) external note { require(wish(src, msg.sender), Vat/not-allowed); gem[ilk][src] sub(gem[ilk][src], wad); gem[ilk][dst] add(gem[ilk][dst], wad); } function move(address src, address dst, uint256 rad) external note { require(wish(src, msg.sender), Vat/not-allowed); dai[src] sub(dai[src], rad); dai[dst] add(dai[dst], rad); }当 keeper 合约调用vat.hope(attackerAdapter)时它会设置can[keeperAddress][attackerAdapter] 1;这意味着攻击者的 adapter 之后可以调用vat.flux()把src设为 keeper 地址并通过wish()检查。也就是说adapter 可以把 keeper 的抵押品转到任何地方。但hope()本身不是 bug。真正的 bug 是 keeper 的0x8804d1de()让任何人触发这次授权然后在授权仍然有效的时候回调了攻击者的合约。四、攻击过程逐步拆解把整个过程拆开看攻击者的操作并不复杂攻击者部署一个恶意 adapter 合约。它的join()函数里写好了抽干逻辑。攻击者调用 keeper 的0x8804d1de(maliciousAdapter, someParam)。因为函数没有访问控制调用成功。keeper 调用Vat.hope(maliciousAdapter)设置can[keeper][maliciousAdapter] 1。keeper 接着调用maliciousAdapter.join()。攻击者的代码在这个调用里执行此时授权仍然有效。恶意 adapter 调用Flipper.deal(1457)。deal()需要wish(bidder, msg.sender)通过而恶意 adapter 现在已经代表 keeper 获得授权所以调用成功。拍卖结算50 ETH 记到 keeper 的 Vat 抵押品余额。对拍卖 1458、1459、1460 重复同样的操作。总共 200 ETH 记到 keeper 名下。恶意 adapter 调用Vat.flux()把这 200 ETH 抵押品从 keeper 地址转到自己地址。最后adapter 调用GemJoin.exit()把 200 WETH 解包成 ETH发送到攻击者地址。整个操作放在一笔交易里完成。攻击者只付了 gas加上最开始从 Tornado Cash 拿到的 0.1 ETH。五、修复其实很简单但它就是没写同一个 keeper 合约里其他特权入口在继续执行之前都会先跑ds-auth检查。它们会把bytes4selector 和msg.sender传进授权逻辑如果检查失败函数会以ds-auth-unauthorized回滚。0x8804d1de()没有这个保护。整个损失就来自这一个缺失的修饰器。正确的修复可以分三层来做。修复一加上ds-auth保护// 修复 1加上和其他特权函数一致的 ds-auth 保护 function 0x8804d1de(uint256 arg0, uint256 arg1) public auth { // auth 修饰器会执行 ds-auth 检查和其他特权入口一样 require(msg.data.length - 4 64); // ... 其余逻辑不变 }DSAuth里的auth修饰器大致是这样modifier auth { require(isAuthorized(msg.sender, msg.sig), ds-auth-unauthorized); _; }它检查msg.sender是不是 owner 或授权地址不是就直接回滚。效果很直接攻击者的调用会被ds-auth-unauthorized挡住后面的hope()和join()根本不会执行。修复二调用结束后撤销授权只加auth还不够最好再做一层纵深防御。函数在外部调用返回后应该立刻撤销授权。// 修复 2外部调用返回后撤销授权 function 0x8804d1de(uint256 arg0, uint256 arg1) public auth { require(msg.data.length - 4 64); // ... 校验和 adapter 准备逻辑 ... if (!alreadyAuthorized) { vat.hope(address(arg0)); } // 执行 adapter 逻辑 address(arg0).call(abi.encodeWithSelector(AdapterLike.join.selector, arg1)); // 立刻撤销授权 if (!alreadyAuthorized) { vat.nope(address(arg0)); } }这一步的意义在于即使 owner 自己犯错或者 owner 私钥被泄露攻击窗口也会被压到最小。授权只在必要的那一小段时间里存在调用一结束就关掉。修复三adapter 地址白名单还有一层值得加不要接受任意合约地址只允许预批准的 adapter。原来的函数只检查了code.size 0。这只能说明目标地址是个合约不能说明这个合约可信。// 修复 3白名单限制可信 adapter mapping(address bool) public trustedAdapters; modifier onlyTrustedAdapter(address adapter) { require(trustedAdapters[adapter], Keeper/unauthorized-adapter); _; } function 0x8804d1de(uint256 arg0, uint256 arg1) public auth onlyTrustedAdapter(address(arg0)) { // ... 函数体 }三层修复放在一起效果是这样的访问控制auth只有授权调用者才能进入这个函数。授权撤销nope即使函数被调用Vat 权限窗口也会在调用返回后关闭。adapter 白名单函数只能和预先批准的合约交互直接消除“任意外部调用”的攻击面。六、这个事件暴露出的智能合约设计问题这起攻击不涉及高深密码学也没有什么新攻击向量。它暴露的是两个被同时违反的设计原则。原则一任何能移动价值的函数都必须验证调用者0x8804d1de()能移动 200 ETH却没有检查msg.sender。这就是全部故事。修复只需要一行修饰器不修复的代价是 54 万美元。这个原则不只适用于简单的onlyOwner。任何授予权限、移动内部余额、把控制权交给外部合约的函数都需要确认调用者有权执行这个动作。同一个合约里其他函数用的ds-auth就是为这个目的设计的。原则二不要在同一笔交易里先授权再调用外部代码却不撤销hope(attacker)然后call attacker.join()这是一个典型的能力泄漏。授权和外部调用是同一个漏洞的两半。攻击者不需要直接调用Vat.hope()keeper 会替他调用。更安全的模式是把授权和回调拆开或者在回调之后强制撤销授权。nope()不是可选项而是必需项。原则三链下基础设施需要和链上合约同等级别的安全审计这个 keeper 的实现合约没有在 Etherscan 上验证。它 2020 年 2 月部署运行了六年没有审计记录。所有者在 2020 年 3 月之后就不再和它交互但合约仍然活着仍然可以被利用。休眠不等于安全。每一个 keeper bot、每一个自动化脚本、每一个持有或管理链上价值的链下组件都应该被当作协议攻击面的一部分。如果它能签交易它就可能被利用。如果任何人都能调用它那它迟早会被调用。七、总结MakerDAO 的核心合约完全按设计工作。Vat、Flipper、GemJoin所有行为都符合规范。漏洞完全在一个没人看着的第三方 keeper 里。六年半的时间里200 ETH 一直躺在那里等着被领取。这次攻击并不复杂。不需要闪电贷不需要价格操纵不需要重入。只需要调用一个本该带修饰器的函数。这才是最让人睡不着的地方。最危险的 bug 往往最简单藏在大家以为“跑了这么多年都没事”的代码里。如果你维护 keeper bot、自动化脚本或者任何和 DeFi 协议交互的链下基础设施现在就去审计。检查每一个能移动价值的函数检查每一个授权点检查每一个外部调用。如果你发现类似0x8804d1de()的函数在别人先找到它之前修掉。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑