区块链
79AU:烧毁的 LP,拦不住代币管理员
79AU 在 10 月 7 日遭遇资金抽取:特权地址先从交易池取走代币,收币地址再卖回池中换走 USDT。大量 LP 已送入销毁地址,代币合约仍保留另一条改动池余额的权限。项目随后宣布收回 15,000 BNB 并完成安全加固。归还交易已经落链,持币人还需要知道剩余管理员能力和补池资金去向。

文章导航
“LP 已烧毁”很容易让人放心。LP 是交易池的份额凭证,项目把它送到销毁地址,大家自然会觉得:至少池子里的钱,项目方拿不走了。发行 79AU 代币的 DeFi 项目 79th Vault,这次恰好从另一处出了问题。10 月 7 日,一个有代币操作权限的地址把池里的 79AU 转给两个卖币地址。池中代币随之变少,报价抬高,拿到代币的地址再把它卖回去,换走 USDT。
10 月 9 日,79th Vault 宣布收回 15,000 BNB,并计划补充流动性、烧毁新获得的 LP。收到归还款当然是好消息。原来已经烧过一次 LP,仍发生了抽取,这让持币人还需要追问代币管理员能对池子做什么。沿着两笔真实取币交易看下去,就能看清这项权限为什么比 LP 销毁比例更值得关心。
1 池子保管两种币,代币合约记录其中一种余额
79AU 是 BNB Chain 上的代币,合约地址为 0xc35e…a9ca。这次涉及的 79AU/USDT 池地址是 0x02d5…da09。池的 factory() 返回 0xcA143Ce32Fe78f1f7019d7d551a6402fC5350c73,与 PancakeSwap 官方 V2 地址表一致。
理解这个池,先把两个账本分开。池合约保存交易用的储备数字,79AU 合约则记录每个地址有多少 79AU。池里的 79AU,实际是代币账本上记在池地址名下的余额。池合约会读取这个余额,并用它与 USDT 的数量计算交易结果。有人若能从代币账本这一层改动池地址的余额,就已经碰到了定价所依赖的数据。
LP 是向池子注入两种资产后拿到的份额凭证。持有人可以交回 LP,按比例赎回池里的资产。将 LP 转到 0x000000000000000000000000000000000000dEaD,通常所说的“烧 LP”,会让这部分凭证退出日常可支配范围。代币合约里的管理员权限独立存在,转移 LP 也不会顺带撤销这些权限。
在 PancakeSwap 的归档参考实现 PancakePair.sol:134–155中,burn()依照交回的 LP 份额分配两种币。这份旧源码用于解释份额赎回,手续费参数属于其历史版本。79AU 这次的取币交易走到了代币合约,使用的是另一个入口。
2 两笔取币交易,都没有先付进 USDT
10 月 7 日 07:41:04 UTC,地址 0x019b…5ca3 调用 79AU 合约,输入以 0x2a2c5923 开头。交易 0x93a9…eaaf 的回执记录了 500,000 枚 79AU 从池地址转向 0xf219…0938,随后池合约发出 Sync,更新储备。四秒后,交易 0xf737…a7d6 重复这一路径,把另外 500,000 枚转给 0xc3e9…a099。
这两笔回执各有一条 79AU 的 Transfer 和一条池的 Sync,没有同笔 USDT 转账或 LP 赎回事件。输入里的三个 32 字节字段,分别对应池地址、收币地址和代币数量。数量按该代币的 18 位小数换算后就是 500,000。链上能看见调用对象、参数与余额更新事件。特权函数的可核源码尚未公开,单靠四字节标识无法确定其名称和内部判断。
调用者:0x019bd8ed017d11af0eb24d28dcdd0f9930c85ca3
目标: 79AU 合约 0xc35ef0564751a27b468f791113c686c8eebaa9ca
入口: 0x2a2c5923
字段 1:池 0x02d50b93e65d4572af5db2eeb6dd3c6ff08fda09
字段 2:收币地址
字段 3:500000 × 10^18
回执: 79AU Transfer(池 → 收币地址),池 Sync
两笔交易都由同一个地址发起,收币地址却不同。它们把“从池子取代币”和“把代币卖掉”拆成了两个动作。持有取币权限的地址负责第一步,拿到代币的地址负责第二步。先撤掉取币地址的权限,可以阻止它继续取得新筹码。已经转出去的代币仍可以进入后续交易。项目在10 月 8 日公告中称,攻击者取得了操作员私钥。公开交易能够确认授权地址执行了这些调用,实际控制者的身份及密钥取得经过仍需调查证据。
Bitquery 的完整调查把两条卖币路径合计为约 1,435 万 USDT。这个数字来自其截至 10 月 8 日 12:53 UTC 的交易归并。下文的取币机制可以从上述两笔直接读取的回执看清,完整金额则沿用 Bitquery 的统计。
3 先减少池里的代币,再按新报价卖回来
假设一个池里有 100 枚代币和 1,000 USDT。忽略手续费时,它们的乘积 x × y = k 为 100,000,池内边际报价约为每枚 10 USDT。正常买币的人要付进 USDT,才能取走代币。两边数量一起变化,乘积约束保持交易前后的资产关系。
现在把正常买入替换成一次管理员取币:从池里拿走 50 枚代币,没有付进 USDT,再让池接受新的余额。储备变成 50 枚和 1,000 USDT,乘积降到 50,000,边际报价变成 20。收币地址手里的 50 枚,随后就能作为卖单重新进入这个池。卖回这 50 枚后,池里的代币数量补回 100 枚,依照新的乘积,USDT 储备只需剩下 500,另 500 USDT 被卖方取走。20 是极小卖出量对应的即时价格,一笔 50 枚的大卖单会不断压低后续成交价,整单所得要按储备变化计算,不能直接用 20 乘以 50。
回执中的 Sync 是报告储备更新的事件。归档参考实现的 _update():73–85在写入新储备后发出这个事件。sync():198–200则是一条更新入口,读取两种代币的 balanceOf(pool) 交给它。其他操作也会更新储备,仅凭事件不能还原本次代币内部的完整调用。之后的卖单会以新储备为基准,交易时的乘积检查无法替代代币层的余额权限控制。
可以在本地用几行算术核对这个差别。正常交易的对照是:先付 1,000 USDT 买走 50 枚,再卖回这 50 枚,忽略费用时恰好取回最初付出的 1,000 USDT。特权路径省掉了第一笔付款,最终收到的 500 USDT 因而直接来自原有流动性。
# 无手续费算术模型;无需钱包、RPC 或交易签名
normal_k = 100 * 1000
normal_buy_out = 100 - normal_k / 2000 # 50
normal_sell_out = 2000 - normal_k / 100 # 1000,抵回买入成本
after_pull_k = 50 * 1000
privileged_sell_out = 1000 - after_pull_k / 100 # 500,取币时未付款
实际成交还会受到池费、代币卖出税、其他交易和重复取币影响。79th Vault 的公开主页列有 3.25% 卖出税,这些因素需要逐笔复算,不能把图中的一半 USDT 当成此次事件的固定损失比例。模型解释的是报价依据被改写后,正常卖单为什么仍能带走钱。
4 撤掉旧地址,合约里的操作能力仍然存在
OPERATOR_ROLE 是这组操作权限的标签,链上角色标识值等于 keccak256("OPERATOR_ROLE")。hasRole 用来查询某地址是否获授这个角色。10 月 7 日 08:24:04 UTC,撤权交易通过 0xca8a…2df 多签合约执行,79AU 发出 RoleRevoked,撤掉原取币地址 0x019b…5ca3 的角色。撤权不会追回先前转出的代币,也不会阻止收币地址继续卖出余币。
第二天 12:37:24 UTC,另一笔多签执行交易把同一角色授予 0x205b…dd07,继续保留并分配这项权限。权限为什么存在、能够处理哪些来源地址、单次和累计额度有多大,都会影响恢复后的风险,换掉私钥只能处理其中一部分。
10 月 11 日的官方更新称,项目已经完成系统安全升级,加强了账户权限、运行控制和防护措施,并保持运营。这份更新没有列出特权函数源码或具体额度、来源限制,因而还需要把升级声明与实际可见的合约权限对照。
10 月 13 日 02:35:25 UTC,BNB Chain 区块 127323745 的只读状态显示,旧地址 hasRole 返回 false,部署者 0xe45c…40bc 和新地址 0x205b…dd07 都返回 true。02:59:54 UTC 的区块 127327011 又显示,这组权限由 DEFAULT_ADMIN_ROLE 管理,0xca8a…2df 多签持有它。零值只是该管理员角色的 ID,持有者仍可分配或撤销组成员。
对准备继续使用这个池的人,最有用的后续披露会是明确的能力说明:操作员能从哪些地址转币、池地址是否在内、由谁批准、限额怎样执行。若项目需要日常奖励或结算,权限可以收束到那项用途。一旦允许从任意来源地址转币,所有相关余额的安全都会取决于管理员密钥。
5 15,000 BNB 回来了,接收地址之后还要继续看
项目公告链接的归还交易发生在 10 月 9 日 08:54:18 UTC,区块 126607298。交易成功,将 15,000 BNB 从 0xa953…4f89 转到公告所指的 0x2bfc…b7b2,输入为空,也没有合约事件。这是一笔 BNB 直接转账,接收地址与项目公布的收款地址相符。
1,435 万 USDT 是调查归并的两条卖币路径所得,15,000 BNB 是项目地址实际收到的归还款。两者币种和估值时间不同。要算剩余缺口,还需要在共同价格时点核算完整流出与实际回收,并明确归还款覆盖了哪些损失,才能得到有意义的返还比例。
公告随后写的是将回收资产补入流动性,并烧毁所得 LP。这个计划至少还有资金兑换或配币、向池注资、生成 LP、将 LP 转走几步。上述 BNB 交易走到项目接收地址就结束了。接下来是否补入这个池,需要顺着对应注资交易继续追踪。持币人之间的实际受偿还取决于安排:补池会改善可交易深度,直接赔付则改变特定用户余额,两种结果需要各自的记录。
当前池子的确已经有另一组状态。区块 127327011 中,销毁地址持有 LP 总量的约 90.41%,这个比例来自 balanceOf(0x…dEaD) / totalSupply() 的同区块计算。比例描述这时的份额分布,不能单靠它把新增份额追溯到那笔归还款。早期调查中的约 79% 属于历史观察:注资增发份额或交回份额赎回资产,会改变 LP 总量。把 LP 转入 dead 地址则增加该地址的余额,普通买卖不会直接改变 LP 总供给。恢复说明应把新份额的来源与注资交易对上。
6 补回池里的钱,还会受谁控制
我会把是否继续使用这个池的判断放在新操作员能否触碰池余额上。项目若保留这项能力,应当公开它的用途和约束,让源代码与实际部署对应起来。多签能减少单把私钥失守的风险,签署者究竟能批准什么操作,仍由它管理的代币权限决定。一个新的 LP 销毁比例,回答不了这一层问题。
归还款已经到达项目公布的地址。持有人接下来需要看见具体的注资或赔付去向,也需要知道同类转币能力是否受限、限制在哪里执行。钱回来了,能够减少这次损失。接下来还要解释,怎样让新放回池里的钱更难被同样方式取走。